
每年高考出分之后的那几天家里亲戚朋友的消息基本就没停过。大家问的问题高度一致“我家孩子考了多少分位次是多少报什么学校合适”起初我还拿着一张一分一段表加一个 Excel 手工筛后来发现效率实在太低一个下午只能筛完两三所学校的往年数据而且特别容易漏掉合适的院校。后来我干脆用 Python 写了一个高考志愿填报辅助指导系统把“分数转位次、按位次匹配院校、冲稳保分级、可视化对比”整条链路自动化前后大概用了不到一个星期就做了个能用的版本。这篇文章就把这套系统从需求拆解、数据清洗到核心算法和 Web 展示的完整过程整理出来想用 Python 做数据分析实战、入门 Flask 开发或者单纯想给身边考生搭个筛选工具的朋友都可以直接参考。1. 需求拆解与整体架构设计1.1 志愿填报这件事本质上是个排序问题我一开始以为志愿填报是个很“玄”的事情后来把规则捋清楚才发现它本质上是一个带约束条件的多目标排序问题给定考生的分数、位次、选科情况和地域偏好在几千条院校/专业组记录里找出录取概率从低到高排列的一组候选组成“冲、稳、保”三个梯度。这里最核心的约束条件有三个一是考生位次要和院校往年录取位次匹配得上不是看裸分二是不同省份的志愿结构不一样有的按院校填有的按专业组填有的按“专业院校”填数据结构必须能兼容三是还得考虑选科要求、体检限制、专业调剂这些硬性条件。把这些约束理清楚之后系统的功能边界就出来了输入是考生的分数、位次、省份和偏好输出是一份按“冲稳保”分档、按往年录取概率排序的推荐院校清单附带校线的位次对比和可视化图表。1.2 模块划分与技术选型这套系统我拆成了四个模块每个模块职责单一方便后面单独替换和调试模块职责主要工具数据层导入、清洗、合并一分一段表与院校录取历史数据pandas、numpy算法层分数转位次、冲稳保分级、推荐排序自研函数 pandas 向量化计算展示层命令行输出、Web 表单查询、图表绘制Flask、matplotlib / pyecharts配置层省份、年份、梯度阈值等参数管理配置文件 命令行参数技术选型上我几乎没有犹豫就选了 Python。原因很直接pandas 处理几万行的录取数据也就是瞬间的事Flask 搭一个简单的查询页面半天就能跑起来matplotlib 做中文图表配合字体设置也比较省事。如果换 Java 或 C#光是把数据清洗这部分重写一遍工作量就会多出两三倍。对于这种“数据量不大、但清洗逻辑繁琐、迭代需求多”的工具型项目Python 的生态优势太明显了。1.3 为什么建议先做命令行版再做 Web 版很多人一上来就想搞个漂亮的网站我的建议是先沉住气做命令行版。原因很简单核心逻辑还没验证之前Web 层只会拖慢你的调试速度。我第一版就是在命令行里直接跑函数输入分数输出一串院校列表等算法没问题了再用 Flask 包一层壳。这样出了问题能快速定位是算法的问题还是页面渲染的问题不会糊成一锅粥。2. 数据准备与清洗系统能不能用全看这一步2.1 你需要哪几类核心数据这套系统依赖的数据有三类缺一不可一分一段表省考试院每年公布包含每个分数对应的同分人数和累计人数。这是“分数”和“位次”互转的依据。院校录取历史数据各院校在目标省份近几年的投档线、最低分对应的位次、平均分、招生计划数、录取人数。院校基础信息院校代码、院校名称、所在城市、办学层次985/211/双一流/普通本科、选科要求等。前两类是算法的主原料第三类用于过滤和增强展示。数据来源以官方发布为准我这里是手工整理成结构化文件再导入后面会讲为什么不建议直接写爬虫硬刚官网。2.2 一分一段表分数会骗人位次不会这里必须强调一个关键认知填报志愿的核心对比量是位次不是分数。每年试卷难度不一样同一分数在不同年份含金量完全不同。比如某省去年 600 分能排到全省第 8000 名今年 600 分可能已经排到第 12000 名了。如果你拿着今年的分数直接去比去年的录取分数结果会偏差非常大。所以系统第一步就是把考生分数通过当年的“一分一段表”转成位次。转换逻辑很简单就是查表import pandas as pd # 一分一段表列名示例score(分数), same_count(本段人数), cum_count(累计人数) rank_table pd.read_csv(one_score_one_rank.csv, dtype{score: int}) def score_to_rank(score: int, table: pd.DataFrame) - int: 分数转位次查累计人数即可 match table.loc[table[score] score] if not match.empty: return int(match[cum_count].iloc[0]) # 分数不在表里比如征集志愿的特殊分取最近的低分段 nearest table.loc[table[score] score] if nearest.empty: raise ValueError(f分数 {score} 低于表内最低分) nearest nearest.sort_values(score, ascendingFalse).head(1) return int(nearest[cum_count].iloc[0])拿到位次之后后面所有比较都基于位次做分数反而退居二线只用来展示“该校往年最低分是多少”。2.3 数据清洗的实战细节与踩坑记录数据清洗是这套系统里最脏最累的活我实际做下来总结了几个高频坑坑一官方数据是 PDF 或网页表格直接复制会有隐形换行和空格。我用 pandas 的read_csv和read_excel导入后第一步永远是strip()掉所有字符串列的前后空格再用正则把“全角空格”和“不换行空格”替换掉。否则后面merge的时候明明是同一个学校名却因为一个空格分成了两行。坑二同一个学校在不同年份的招生代码可能变化不能拿“院校名称”当唯一键。我用的是“院校代码 年份”作为复合主键。如果院校代码在年份之间有变化还得额外维护一张“新旧代码对照表”这是最让人头大的部分。坑三招生批次要分清本科批和提前批不能混在一起比。提前批的分数波动和规则跟普通本科批差异很大我的处理方式是给每条记录加一个batch字段推荐时默认只用普通批次提前批单独作为附加信息展示。一个清洗后的录取数据样例如下院校代码院校名称招生省份年份批次最低分最低位次平均分计划数1001示例大学河北2024本科批6126850618321001示例大学河北2023本科批6087310615301002示例理工大学河北2024本科批598954060445清洗完成之后建议把它们统一导出成 CSV 的规范格式存档后面算法层每次跑直接从 CSV 读入避免反复清洗同一份数据。3. 冲稳保推荐算法的设计与实现3.1 冲稳保的数学定义“冲稳保”是考生圈子里流传很广的说法但到底多大的差值算“冲”、多大的差值算“保”每个人的标准都不一样。我在系统里把这套经验量化成了位次差比例。定义考生位次为student_rank某院校往年最低录取位次为school_min_rank那么gap_ratio (school_min_rank - student_rank) / student_rank这里的逻辑是位次数值越小说明全省排名越靠前院校门槛越高。如果school_min_rank明显小于student_rank说明这所学校往年录到的最低排位都比考生靠前考生属于“高攀”这就是冲。如果两者相当考生处于院校往年录取线附近这就是稳。如果school_min_rank明显大于student_rank说明考生位次在院校往年录取线之后基本比较稳这就是保。默认阈值我设在-0.05和0.10两档也就是位次差在 -5% 到 10% 之间算“稳”低于 -5% 算“冲”高于 10% 算“保”。这两个阈值我在系统里做成了参数因为不同省份、不同分数段的分布密度不一样理科高分段和文科中分段使用同一套阈值并不合理需要根据验证结果微调。3.2 三年数据如何合并使用只拿一年数据做判断很容易遇到“大小年”问题——某所学校某年突然爆冷或爆热录取位次大幅波动如果你只看那一年推荐结果就会失真。我的做法是对同一院校取近三年最低位次的加权平均越近的年份权重越高import pandas as pd import numpy as np admission pd.read_csv(admission_history.csv, dtype{school_code: str}) def compute_weighted_rank(group: pd.DataFrame) - float: 近三年位次加权年份越近权重越高 weights {2022: 1, 2023: 2, 2024: 3} total_weight sum(weights.get(y, 0) for y in group[year]) if total_weight 0: return group[min_rank].mean() return float((group[min_rank] * group[year].map(weights)).sum() / total_weight) recent admission[admission[year] 2022].copy() school_agg recent.groupby(school_code).apply( lambda g: pd.Series({ school_name: g[school_name].iloc[0], weighted_rank: compute_weighted_rank(g), latest_score: g.loc[g[year].idxmax(), min_score], }), include_groupsFalse ).reset_index()加权平均比简单平均更贴近“院校近年真实水平”因为它把去年那种突发性的大小年波动做了平滑处理。3.3 核心推荐代码实现有了上面的基础核心推荐函数就非常清晰了。整体流程是分数转位次 - 过滤招生省份 - 计算位次差比例 - 冲稳保分级 - 按梯度数量截取。def recommend(score: int, province: str, rush_ratio: float -0.05, safe_ratio: float 0.10, top_rush: int 12, top_stable: int 20, top_safe: int 12) - pd.DataFrame: 生成冲稳保推荐列表 # 1. 分数转位次 student_rank score_to_rank(score, rank_table) # 2. 过滤目标省份的招生记录 df school_agg[school_agg[target_province] province].copy() if df.empty: raise ValueError(f没有找到 {province} 的录取数据) # 3. 计算位次差比例并分级 df[gap_ratio] (df[weighted_rank] - student_rank) / student_rank def classify(r): if r rush_ratio: return 冲 if r safe_ratio: return 稳 return 保 df[level] df[gap_ratio].apply(classify) # 4. 按梯度截取 rush df[df[level] 冲].sort_values(gap_ratio).head(top_rush) stable df[df[level] 稳].sort_values(gap_ratio).head(top_stable) safe df[df[level] 保].sort_values(gap_ratio).head(top_safe) # 5. 输出并附带等级 result pd.concat([rush, stable, safe], ignore_indexTrue) return result[[school_code, school_name, weighted_rank, latest_score, gap_ratio, level]]这里有个容易被忽略的细节冲和稳内部的排序方向要区分。冲的院校应该按“最难到稍微不那么难”排序也就是gap_ratio从小到大把最想冲的放前面保的院校则建议把“最保险”的放在最后我给保底列表的排序也是按gap_ratio升序这样列表中越靠后的院校位次越靠后、录取概率越高作为最后的兜底。3.4 志愿梯度到底怎么分配算法输出的是候选池真正填志愿的时候还要讲究梯度分配比例。以常见的填报规则为例具体数量按本省要求来我建议的分配方式如下梯度志愿数量占比位次差比例范围录取概率预期冲20% 左右-12% 到 -5%低约 20%-40%稳55%-60%-5% 到 10%中约 50%-80%保20%-25%10% 以上高约 90% 以上这个比例分配不是拍脑袋定的。冲太多容易全滑档保太多又浪费了分数的价值所以中间“稳”的部分一定占大头。系统里我把每个梯队的数量做成参数就是为了方便考生根据自己“求稳”还是“求冲”的心态灵活调整。4. 系统实现从命令行到可视化页面4.1 命令行版十分钟验证核心逻辑核心算法写完后我先做了个命令行版本用argparse接收参数直接跑出结果python recommend_cli.py --score 605 --province 河北 --top-rush 10 --top-stable 15 --top-safe 10输出就是一张表格列包含院校名称、加权最低位次、最新最低分、位次差比例和冲稳保等级。命令行版的价值在于验证和调试我可以快速跑几十组不同的分数检查推荐的院校是否合理。如果发现某所明显排名很高的学校被分到了“保”那就是数据清洗出了问题这时候排查起来非常快。4.2 用 Flask 包一层 Web 表单逻辑验证没问题后我开始加 Web 层。Flask 是最轻量的选择一个app.py加一个模板文件就够。我的核心是让用户输入分数和省份点击查询后渲染出推荐表格。from flask import Flask, render_template, request app Flask(__name__) app.route(/, methods[GET, POST]) def index(): result None error None if request.method POST: try: score int(request.form[score]) province request.form[province] result recommend(score, province) except Exception as e: error str(e) return render_template(index.html, resultresult, errorerror) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)模板里用 Jinja2 渲染表格。这里有个实战建议不要把 pandas 的 DataFrame 直接传给模板先用itertuples()转成普通对象或者用to_dict(records)转成字典列表模板遍历起来更干净也能避免 Jinja2 和 pandas 某些类型转换的兼容问题。{% if result is not none %} table tr th院校名称/th th加权最低位次/th th最新最低分/th th位次差比例/th th梯度/th /tr {% for row in result.to_dict(records) %} tr td{{ row.school_name }}/td td{{ row.weighted_rank }}/td td{{ row.latest_score }}/td td{{ %.2f%%|format(row.gap_ratio * 100) }}/td td{{ row.level }}/td /tr {% endfor %} /table {% endif %}4.3 数据可视化让梯度一眼看懂表格能看数据但“冲稳保”的梯度结构如果不画图考生和家长很难直观感受。我用 matplotlib 画了一张横向条形图把每个推荐院校按“位次差比例”排在一条时间轴上用三种颜色区分冲稳保。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] # 解决中文乱码 plt.rcParams[axes.unicode_minus] False def plot_gradient(result: pd.DataFrame, output: str gradient.png): df result.sort_values(gap_ratio) colors df[level].map({冲: #d9534f, 稳: #f0ad4e, 保: #5cb85c}) labels df[school_name] ( df[level] ) fig, ax plt.subplots(figsize(12, max(6, len(df) * 0.4))) ax.barh(labels, df[gap_ratio] * 100, colorcolors, alpha0.8) ax.axvline(0, colorgray, linestyle--, linewidth0.8) ax.set_xlabel(位次差比例 (%)) ax.set_title(冲稳保梯度分布) plt.tight_layout() plt.savefig(output, dpi150)这张图生成之后家长看一眼就能明白红色部分是高攀的冲刺院校绿色部分是兜底的保底院校中间黄色是主体。实际给亲戚演示的时候图表比任何文字解释都管用这算是这个项目最有“交付感”的一个功能。5. 常见问题与排查技巧实录5.1 数据问题院校推荐明显偏离常识项目调试过程中我遇到最多的就是推荐结果“一看就不对”。比如一名位次 2 万名的考生系统却推荐了往年录取位次 2000 名的顶尖院校到“冲”区。排查后发现是录取数据里混入了“中外合作办学”或“较高收费专业”等特殊招生代码这些代码的最低分位次虚低不能和普通专业混在一起比。解决方法是清洗时增加一个fee_type或remark字段把普通类、中外合作、民族班、定向生分开存储推荐时只使用普通类的数据。5.2 算法问题阈值设得不对导致梯度失衡有段时间我发现系统输出的“保”太少即便位次差已到 20% 以上才给保。反过来另外一些分数段冲的比例又过大。原因就是固定阈值在高分段和低分段表现不一致。高分段考生人数稀疏位次差 1000 名对应的比例变化就很大低分段考生密集位次差 1000 名可能只是几个百分点的变化。后来我把阈值从“固定值”改成“分数段自适应”600 分以上用更保守的阈值500 分以下用更宽松的阈值。这个逻辑不复杂但效果提升明显。这也验证了一个经验任何经验阈值都要用真实数据回头验证不能拿来就用。5.3 环境问题中文乱码、数据导入编码报错两个月后我把项目换到另一台电脑跑结果所有图表的中文全成了方块。这是 matplotlib 默认字体里没有中文字体导致的解决办法就是前面代码里那两行plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False但如果运行环境是 Linux未必装了 SimHei就需要提前确认系统里有没有中文字体没有的话要安装字体或者改用WenQuanYi等开源字体。另外 CSV 文件的编码问题也容易踩官方数据导出的 CSV 有的是GBK有的是UTF-8-sig统一用pd.read_csv(..., encodingutf-8-sig)读取比较稳不容易式样翻车。5.4 使用建议系统是辅助不是决策最后说句掏心窝的话。这类系统能帮考生完成“海选”——从几千条记录里筛出二三十个候选这确实是人手工很难高效完成的事。但它不能替你做最终决定原因有三系统依赖的是历史数据预测不了今年的报考热度变化。系统没法替你判断“专业”和“学校”哪个更重要这是个人价值观问题。系统也没有处理调剂和退档的完整风险模型这部分必须结合考生本人是否服从调剂来判断。我个人的使用建议是用系统跑出三档列表之后每一档里再人工看招生章程确认单科成绩、体检要求、选科限制这些硬条件是否满足然后再排序。这个“机器筛、人来定”的分工方式是这套系统最合理的定位也是我后来给所有家人朋友推荐的用法。5.5 后续可以扩展的方向这套系统目前的版本只做到了“基于位次的院校筛选和排序”如果要往深了做至少还有这几个方向值得探索一是加入专业级数据和专业录取位次实现“院校专业”双重推荐二是做多年度波动分析用标准差量化院校的热度稳定性辅助判断“大小年”风险三是把选科要求、体检限制做成规则引擎让过滤条件可配置化。这些方向不需要推翻现有设计只是往数据层和规则层继续加内容这也算是我做这个项目得到的一个体会架构上把数据、算法、展示拆干净后续扩展真的会轻松很多。