☰
基于Python的高校招生分数预测与可视化系统
2026/10/3 7:30:51 网站建设 项目流程

简介:面向高校毕业设计与课程设计场景,本系统以Python为核心,完整实现了高校历年招生分数数据的爬取、清洗、存储、查询与可视化。项目使用爬虫抓取高考网录取分数线,经预处理后存入文件系统与数据库,基于Flask提供查询接口,并通过ECharts展示最低/最高/平均分分布、录取批次及专业趋势等,适合具备Python基础并希望上手数据分析Web项目的学习者。压缩包共46个文件,以py源码、html模板、js与css前端资源、db数据库及mov演示视频为主,整体大小78.47MB,目录结构便于定位爬虫、业务逻辑和静态资源。已有133人学习,包内含完整源码、spider爬虫脚本、ECharts可视化配置、演示录屏及依赖说明,可直接运行对照,也可在此基础上扩展模糊搜索与专业分析功能,是毕业设计或课设的实用参考资料。

1. 基于Python的高校招生分数系统:一份能跑通全链路的毕业设计

用Python做高校历年招生分数的研究与可视化,听起来像是“画几张折线图”就收工,但真正能拿得出手的毕业设计,是把数据分析、机器学习预测和Flask Web前端串成一条完整链路:从原始表格里的脏数据,到随机森林模型预测下一年的录取位次,再到浏览器里可交互查询的趋势图和专业对比。这个方向适合两类人——想用一份代码同时展示数据分析和Web开发能力的学生,以及在面试时需要当场演示项目的求职者。它解决的问题很具体:招生分数数据分散、难对比、预测靠经验,而系统能把“经验”变成可复用的数据管道。

2. 招生分数数据获取与清洗:从原始表格到可建模的数据集

2.1 先定字段:招生分数数据需要哪几列才能撑起后续建模

在动手写代码之前先做一件事:把数据字典定下来。没有字段设计就急着清洗,后面构造特征时一定返工。做这个方案时,我会把每条记录定义成“一个省、一个学校、一个专业、一个年份的录取结果”,需要的核心字段如下表。

字段说明在建模里的角色
province省份,如河南、山东、广东分组与编码特征
year录取年份时间切分与趋势特征
school_code院校代码分组与对齐
school_name院校名称展示与归一化
major专业名称分组与展示
batch批次(本科一批/本科二批等)分类特征
subject科类(文科/理科/综合)分类特征
plan_count计划招生人数数值特征
min_score最低录取分数展示与目标变量
min_rank最低录取位次核心目标变量

其中“最低位次”是整份数据里最有价值的列。位次是省内的相对排名,不受每年试卷难度和分数线波动的影响,做机器学习预测时,位次比分数稳定得多。后面建模会以位次作为核心目标变量,分数反而放在展示层。

数据从哪来?常见做法是各省教育考试院公布的录取统计表,每年投档结束后会发布Excel或CSV;院校招生官网的“历年分数”页也可以作为补充。很多同学第一反应是写python爬虫去抓,但招生数据更适合直接整理公告表格,爬虫容易被反爬挡住,而且字段经常对不齐。宁可前期手工合并三到五年的公告表格,也不要花大量时间调爬虫。

同时要注意院校名称的写法,同一个学校在不同年份、不同省份的表格里可能出现“北京师范大学(珠海校区)”和“北京师范大学珠海校区”这种差异。这一列如果不做归一化,后面分组聚合、画趋势图都会错位。

2.2 用pandas清洗招生分数数据:缺失值、异常值与院校名归一化

下面这段是数据清洗最核心的处理,实际操作时可以整理成一个clean.py文件。这里用pandas和numpy处理,这也是python数据分析里最基础、最常用的一套组合。

import pandas as pd import numpy as np # 读取原始表格,utf-8-sig 编码避免中文乱码 df = pd.read_csv("admission_raw.csv", encoding="utf-8-sig") df.columns = ["province", "year", "school_code", "school_name", "major", "batch", "subject", "plan_count", "min_score", "min_rank"] df = df.dropna(subset=["min_score", "min_rank"]) df = df[(df["min_score"] >= 0) & (df["min_score"] <= 750)] df = df[df["min_rank"] > 0] # 院校名归一化:去掉空格、全角括号转半角 df["school_name"] = df["school_name"].str.replace(" ", "") df["school_name"] = df["school_name"].str.replace("(", "(") df["school_name"] = df["school_name"].str.replace(")", ")") # 同一个院校代码出现多种写法的,以代码为准补齐 code_map = df.drop_duplicates("school_code").set_index("school_code")["school_name"] df["school_name"] = df["school_name"].fillna(df["school_code"].map(code_map)) # 同一年同一学校同一专业的重复公示记录只保留一条 df = df.drop_duplicates(subset=["province", "year", "school_code", "major", "batch"]) df = df[df["plan_count"] > 0] df.to_csv("admission_clean.csv", index=False, encoding="utf-8-sig") print(df.shape) print(df.isnull().sum())

代码逻辑说明:dropna先去掉缺少最低分和位次的记录;分数区间限制在0到750之间,这是高考总分的基本约束,个别省份的等级赋分制总分仍然不超过750。位次为0的记录多数是缺数,直接删掉比插值填充更安全。院校名归一化这步,用replace而不是strip是因为中间空格也需要处理;全角括号的影响是重名判断,全角半角不一致会导致同一个专业被当成两个。最后的drop_duplicates是防止同一年同一专业出现多条重复公示,按计划招生人数再过滤一次,把明显不完整的记录排除掉。

参数修改建议:read_csv里的encoding要和源文件保持一致,从Excel另存的CSV通常带BOM,用utf-8-sig最稳妥,否则第一列列名会变成带\uFEFF前缀的脏字符串。分数上限按实际考试类型调,如果数据里混入艺术类、体育类,要注意它们的总分和普通类不同,需要在前面先按科类分组再处理。这一整步做完,数据量通常能收缩两到三成,收缩幅度过大时先检查缺失值和异常值是不是超过一半。

2.3 特征构造:为什么位次比原始分数更适合做训练目标

清洗完后离建模还差一步:把每条记录变成一行“能学习的”特征向量。直接拿“年份、学校、专业、分数”去训练机器学习模型,效果会很差,因为每年的分数波动主要由试卷难度、考生人数变化引起,模型很难学到规律。

常见做法是构造三类特征:历史统计特征、院校固定特征、排名特征。历史统计特征包括这个学校、这个专业、这个省份过去三年的平均最低位次和分数;院校固定特征包括院校代码、批次、科类;排名特征就是上一年度的最低位次。目标变量设置为当年最低位次,而不是最低分。

# 先按省、学校、专业、年份排序,保证 shift 取到的是"上一年" df = df.sort_values(["province", "school_code", "major", "year"]) # 构造历史均值特征:同一个 school+major 按年份升序取前三年均值 hist = ( df.groupby(["province", "school_code", "major"])["min_rank"] .transform(lambda s: s.shift(1).rolling(3, min_periods=1).mean()) ) df["hist_rank_avg"] = hist.astype("Int64") # 上一年同位次做特征 df["last_rank"] = ( df.groupby(["province", "school_code", "major"])["min_rank"] .shift(1) ) df = df.dropna(subset=["last_rank", "hist_rank_avg"]) df.to_csv("admission_feature.csv", index=False, encoding="utf-8-sig")

代码逻辑说明:shift(1)取上一年,rolling(3)取过去三年滑动均值,min_periods=1允许早期年份只有一条历史记录也能参与训练,避免删掉太多冷启动数据。用transform而不是直接groupby,是为了让结果对齐到原始行序,否则合并时索引会乱。这些特征构造完之后,每条数据都变成了“今年位次约等于历史均值加上一年位次加招生人数变化”的可回归问题,模型只需要在误差项上做文章。

参数说明:rolling窗口选3年是经验值。窗口太短,偶然波动会被模型当真;窗口太长,早期数据无法覆盖近年的扩招和批次调整。min_periods=1在这类小数据集里很关键,因为很多专业只在个别年份招生,强行要求三年完整历史会丢掉大量样本。这里把hist_rank_avg转成Int64是因为计算后会出现缺失值,Int64是pandas里允许NaN的整数类型,后面喂给sklearn时再转成float。

3. 机器学习预测录取位次:回归建模与评估

3.1 分数线预测是回归问题:模型选型与时间切分的边界

这一步进入机器学习建模。先说清楚一个问题:预测“明年某专业的录取位次”是回归任务还是分类任务?有人会把分数线分成高、中、低三档做分类,但毕业设计和实际使用场景需要的是可比较的量化结果,所以用回归——预测连续数值。

模型选型上,线性回归适合做baseline、做数据检查和特征方向验证;实际主力用随机森林。原因有三点:招生数据样本量不大且特征中有分类变量,随机森林对类别型特征不敏感,不需要做繁重编码;历史分数与今年分数之间有关系但非线性,随机森林能拟合交互作用;随机森林自带的feature_importances可以输出哪些特征在预测中起作用。更复杂的梯度提升树也能用,但在这个数据规模下收益不明显,而且调参成本和过拟合风险更高。神经网络在这个数据量下没有优势,参数多、容易过拟合,答辩时也说不清可解释性。

数据划分是最容易踩坑的地方。这个场景绝对不能随机划分,需要按年份切分,用早年的数据训练,晚年的数据验证。如果用留出法随机打乱,同一年、同一学校的数据会同时出现在训练集和测试集,模型相当于见过答案再考试,指标虚高到没法参考。

from sklearn.ensemble import RandomForestRegressor # 用作训练的特征列 feature_cols = ["province_code", "batch_code", "subject_code", "plan_count", "hist_rank_avg", "last_rank", "year"] X = df[feature_cols] y = df["min_rank"] # 按年份切分:2020 年及以前训练,2021 与 2022 验证 train_mask = df["year"] <= 2020 test_mask = df["year"] >= 2021 X_train = X[train_mask] y_train = y[train_mask] X_test = X[test_mask] y_test = y[test_mask] # 随机森林回归 rf = RandomForestRegressor( n_estimators=300, max_depth=10, min_samples_leaf=5, n_jobs=-1, random_state=42, ) rf.fit(X_train, y_train)

代码逻辑说明:train_mask按年份构造布尔索引,确保训练集里没有任何一条2021年以后的数据。特征里的province_code、batch_code、subject_code要提前从原字段做label encoding再喂给模型,实际操作可以在清洗阶段把省份、批次、科类各自转成整数编码。random_state固定为42,保证不同同学复现结果一致,这个习惯在毕业设计里特别重要,答辩评审问“你这个结果能复现吗”,直接改seed跑一遍就能回答。

参数说明:n_estimators=300在几千条样本规模下训练几秒内完成,再增大对精度提升有限。max_depth限制在10左右,防止模型对训练集里的单年波动记得太死。min_samples_leaf设5,让每个叶节点至少有5条样本,减少高杠杆点影响。这几个参数是这类表格数据的保守起点,样本量更小就把max_depth降到6、min_samples_leaf调到10。

3.2 用特征重要性和误差指标判断模型质量

随机森林训练完之后,不要直接看R²就完事。在这个课题里,我更看重两个指标:MAE和RMSE,单位是“位次”。比如一个专业的最低录取位次是12000名,MAE=800意味着平均误差在800名附近,换算成分数一般在3到8分之间,这种精度足以支撑“冲稳保”的志愿参考。

from sklearn.metrics import mean_absolute_error, mean_squared_error import numpy as np pred = rf.predict(X_test) # 位次单位下的绝对误差与均方根误差 mae = mean_absolute_error(y_test, pred) rmse = np.sqrt(mean_squared_error(y_test, pred)) print(f"MAE in rank: {mae:.1f}") print(f"RMSE in rank: {rmse:.1f}") # 特征重要性排序,能看到模型主要靠什么做出判断 feat_imp = pd.Series(rf.feature_importances_, index=feature_cols).sort_values(ascending=False) print(feat_imp.round(3))

代码逻辑说明:MAE是平均绝对差距,RMSE因为平方把大误差放大。两个指标结合起来看:MAE小但RMSE大,说明大部分记录预测得不错,只是个别院校的波动误差很大,这类院校通常是大小年明显的学校。特征重要性输出可以看到last_rank和hist_rank_avg通常排前两位,plan_count也有一定影响,year在早期几年有影响,说明扩招和批次合并确实改变了位次走势。

另一个建议是画一张“预测位次 vs 真实位次”的散点图,点密集分布在y=x直线附近说明模型稳定,远离直线的点基本集中在清华、北大这类高位次学校。这些学校位次常年个位数,预测值稍微偏一点点误差就显眼,不用刻意处理,在答辩时解释清楚就行。

3.3 模型保存:训练一次,预测接口复用

训练和预测要分开。每次启动Flask时重新训练一遍不是不行,但会在首页加载上浪费几十秒,而且随机种子的维护很麻烦。常见做法是把训练好的模型和特征列名一起序列化到磁盘,这相当于给整个系统留了“后悔药”:模型不对就重训覆盖,Web端代码一行不用改。

import joblib # 保存模型和特征列顺序,供 Web 端加载预测 artifact = { "model": rf, "feature_cols": feature_cols, } joblib.dump(artifact, "admission_model.joblib") # 加载端示例 loaded = joblib.load("admission_model.joblib") model = loaded["model"] cols = loaded["feature_cols"]

代码逻辑说明:joblib.dump将模型与特征列表打包到一个文件,Flask启动时一次性加载,预测接口调用model.predict时只需按cols的顺序构造输入数组。这里把feature_cols一起保存非常关键,否则预测时特征顺序错位,模型会静默给出一个看似正常但完全错误的位次,这类问题最难排查。

4. 用Flask做数据分析与可视化:路由、图表与模型部署

4.1 Flask应用骨架:为什么选Flask而不是其他Web框架

标题指定了flask web,这里说清楚选型逻辑。毕业设计场景下需要一个轻量、可解释性强、方便答辩现场演示的Web应用。Flask的核心优势是路由简单、扩展清晰,一个app.py加一个templates目录就能跑起来,不需要像Django那样强行套用完整的项目结构。这个方案体量就三个页面:首页、专业分析页、预测查询页,用Flask的蓝图拆分路由比把所有视图写在一个文件里更规范。

工程目录大致长这样:

admission_system/ ├── app.py ├── clean.py ├── train_model.py ├── admission_feature.csv ├── admission_model.joblib ├── code_maps.joblib ├── templates/ │ ├── index.html │ ├── major.html │ └── predict.html └── static/ └── echarts.min.js

这个结构里,clean.py负责数据清洗,train_model.py负责建模和保存模型,app.py只做Web展示和预测接口。职责分离之后,哪个环节出错都只改一个文件,不用在几百行的app.py里翻来找去。依赖安装用pip一次性装齐:pandas、scikit-learn、flask、joblib,这几项是运行基础。很多同学会在一开始就被python安装和环境配置卡住,建议用Anaconda统一管理Python 3.8以上的版本,装完依赖后用python -c "import flask"验证安装是否成功。

4.2 路由与预测接口:API返回JSON,前端接管交互

Flask应用骨架与预测接口是整个系统的核心,代码如下:

from flask import Flask, render_template, request, jsonify import joblib import numpy as np app = Flask(__name__) # 启动时加载模型,只加载一次 artifact = joblib.load("admission_model.joblib") model = artifact["model"] feature_cols = artifact["feature_cols"] # 批次、科类字符串到编码的映射,训练前保存一份 code_maps = joblib.load("code_maps.joblib") @app.route("/", methods=["GET"]) def index(): return render_template("index.html") @app.route("/api/predict", methods=["POST"]) def api_predict(): data = request.get_json() # 中文转编码 province_code = code_maps["province"][data["province"]] batch_code = code_maps["batch"][data["batch"]] subject_code = code_maps["subject"][data["subject"]] feature_array = [province_code, batch_code, subject_code, int(data["plan_count"]), float(data["hist_rank_avg"]), float(data["last_rank"]), int(data["year"])] feature_vec = np.array([feature_array]) pred_rank = int(round(model.predict(feature_vec)[0])) return jsonify({"pred_rank": pred_rank}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)

代码逻辑说明:code_maps和admission_model.joblib一样,都是在训练阶段生成的映射字典,把省份、批次、科类的中文名称映射到训练时的整数编码。预测接口接收JSON格式请求,前端把表单内容组装成JSON,后端做编码转换后喂给模型。两个容易遗漏的细节:特征顺序必须与训练时的feature_cols完全一致;year这个特征在预测时输入未来年份,相当于时间趋势项。

参数说明:debug=True只用于本地开发,答辩前要改成debug=False,否则报错页会暴露源码路径。host="0.0.0.0"允许局域网内其他设备通过IP访问,演示时直接用手机或另一台电脑打开5000端口即可。端口被占用时改成5001就行,没有其他影响。

4.3 ECharts可视化:历年分数趋势图和预测对比

可视化这里是翻车高发区:ECharts渲染的数据格式对不上。常见错误是后端直接返回DataFrame,前端用点语法取值,结果页面上一片空白。正确做法是后端把数据转成“数组套对象”的JSON,前端再用map提取字段。

// 以 trend 图为例:x 轴年份,y 轴最低分 fetch("/api/school_trend?school=某大学&major=计算机类") .then((resp) => resp.json()) .then((data) => { var chart = echarts.init(document.getElementById("trend")); chart.setOption({ xAxis: { data: data.map((item) => item.year) }, yAxis: { type: "value", name: "最低分" }, series: [{ type: "line", data: data.map((item) => item.min_score), smooth: true, }], }); });

代码逻辑说明:前端拿到后端返回的JSON数组后,逐项映射到坐标轴。data.map在这里负责把数组对象里的year和min_score字段提取出来,ECharts才能按坐标轴渲染。注意如果不提取直接丢整个对象进去,图表大概率白屏,控制台会报数据格式不匹配。

后端在返回时要注意中文编码。Flask的jsonify默认会把中文转成可读的UTF-8字符串,但如果你在某个接口里为了拼接方便手动用了json.dumps,一定要加上ensure_ascii=False,否则前端拿到的全是\uXXXX。还有一个细节:ECharts的JS文件放在static目录,模板里用url_for('static', filename='echarts.min.js')加载,不要写死相对路径,否则路由带前缀时容易404。

演示视频里最值得录的不是点几个页面,而是预测接口的调试过程:输入年份、批次、科类和上一年位次,返回预测位次,这个画面能同时体现前端、后端和机器学习三块能力。

5. 避坑清单:招生分数系统里最常见的五个翻车点

这部分按“现象→原因→解决”写,都是这个课题里反复出现的问题。排查时先按清单过一遍,能省下大量翻代码时间。

5.1 训练集R²虚高:数据泄漏是怎么混进去的

现象:模型在测试集上的R²高达0.97,MAE只有几十名,但把它拿到下一年的真实数据上预测,误差一下拉到几千名,完全不可用。

原因:没有按年份切分数据。训练集和测试集混入了同一年份的记录,而学校往年的位次本身就是今年位次的强特征,模型相当于考试时翻了书,指标自然漂亮。

解决:严格使用train_mask = df["year"] <= 2020这种按时间切分的方式。同时要检查特征里有没有“预测时拿不到”的列,比如当年实际录取人数、当年最低分,这类字段在真正预测明年数据时根本不存在,必须去掉。

5.2 预测位次出现极小值或负值

现象:预测某校某专业位次返回300,而这个专业往年位次是8000,明显不合理。

原因:随机森林本身不会输出负值,但如果训练数据里有单一年份的异常点,比如某校爆冷位次骤降,且树的min_samples_leaf设得太小,模型就会记住这个异常点,预测时把它当成正常规律输出。

解决:训练前剔除位次小于10的极端记录,以及位次突然变化超过5倍的离群点;预测接口中对结果加约束,当pred_rank小于1000时给出提示,并展示该校近三年的实际位次区间,让使用者自行判断。

5.3 Flask启动慢或页面卡顿

现象:页面第一次打开要等十几秒,再点一次又恢复正常,多刷新几次内存占用越来越高。

原因:把joblib.load写在了路由函数内部,每次请求都重新读一遍模型文件。随机森林有几百棵树,反序列化需要时间,而且加载的对象没有及时释放。

解决:模型加载放在模块顶层,进程启动时只做一次,之后所有请求共用同一个模型对象。这是Flask部署里的基本常识,但在毕业设计的论坛源码里反复出现,值得单独列出来。

5.4 新高考批次合并导致历史断层

现象:2020年前的“本科一批/本科二批”在2021年后合并为“本科批”,同校同专业位次出现断崖式变化,模型预测误差突然增大。

原因:合并后原二批专业涌入一批考生,位次结构变化;而批次字段在训练时被当成分类特征,模型学到的“一批低、二批高”规律在新数据上失效。

解决:构造“批次改革前/后”的年份标记列,或者把批次字段拆成改革前后的两列;预测时不依赖新老批次,而是输入历史位次区间,让批次影响交给历史特征去吸收。

5.5 中文乱码贯穿清洗与API

现象:页面标题正常,但ECharts的图例或提示框显示成\u9ad8\u6821,CSV打开后第一列列名带着乱码前缀。

原因:CSV读取时编码用的是utf-8,文件实际带BOM;JSON返回时手动调用json.dumps,ensure_ascii默认是True,中文字符全被转成Unicode转义。

解决:CSV统一用utf-8-sig读取和写入;所有返回JSON的地方走Flask的jsonify,不要手动拼接。如果确实要手动构造JSON,写json.dumps(data, ensure_ascii=False)。这两行修完,九成乱码问题消失。

6. 把系统做得更实用:专业热度、预测区间与滚动回测

基础版本跑通之后,可以加三个锦上添花的能力。第一个是专业热度分析。志愿场景里没有现成的“热度”字段,我一般用“位次变化率”做代理指标:连续三年位次持续上升的专业,说明关注度在涨;位次波动剧烈的专业,则存在明显的大小年效应。这个分析不需要额外数据源,直接从清洗好的表里分组计算即可。

第二个是预测区间。随机森林的每棵树都是独立预测器,可以利用树与树之间的差异估算不确定性,而不是只输出一个孤零零的数字。

import numpy as np # 构造一条待预测的特征向量 feature_vec = np.array([province_code, batch_code, subject_code, plan_count, hist_rank_avg, last_rank, year]).reshape(1, -1) # 用每棵树单独预测,取分位数作为区间 tree_preds = np.array([tree.predict(feature_vec)[0] for tree in model.estimators_]) low_rank = np.percentile(tree_preds, 10) high_rank = np.percentile(tree_preds, 90) print(f"预测位次区间:{low_rank:.0f} ~ {high_rank:.0f}")

代码逻辑说明:feature_vec的形状必须是(1, 7),对应训练时的7个特征。model.estimators_返回所有决策树,每一棵都做一个预测,最终得到300个预测值。np.percentile取第10和第90百分位,得到偏保守的区间,比直接输出单个预测值更符合志愿填报的“冲稳保”场景。

第三个是滚动回测,用来验证模型不是只在某一年灵验。用2016到2019年训练预测2020年,再用2016到2020年训练预测2021年,逐年滚动,把每年的MAE列成一张表。如果逐年误差稳定,说明系统可以投入使用;如果某一年误差突然变大,通常对应批次调整或招生政策变化,也方便写进论文分析。

最后说一个我自己的习惯:做这类毕业设计系统,我每次都会在源码里留一份README,把数据格式、跑通命令、所有依赖写清楚,因为答辩结束三个月后你很可能完全忘了当时怎么启动的。这份文档比代码本身更能避免返工。希望帮到你。

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

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

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

立即咨询