首页 / 资讯中心 / 文章详情

用Python搭建考研院校数据分析系统:从数据采集到可视化报告全流程

用Python搭建考研院校数据分析系统:从数据采集到可视化报告全流程 ★ FEATURED ARTICLE
每年三、四月份总会有同学捧着厚厚一叠往年录取数据来找我帮忙分析。来回处理过几轮之后我干脆用 Python 搭了一套考研院校数据分析系统把数据采集、清洗、入库、分析、可视化、报告导出全部串起来最后连源码、数据库、文档也一并整理成可以交付的完整项目。这篇文章就把这套系统的核心设计、实现细节和踩过的坑完整拆开讲适合正在做择校决策的人也适合想通过一个完整项目练手数据分析的初学者。很多人觉得择校就是看看分数线、问问学长学姐但真当你要在几十所院校、上百个专业里做比较时纯靠手工翻表格很快就会被信息淹没。这个系统解决的就是这个问题把分散在不同公告、不同年份的数据统一进数据库用可量化的指标计算院校和专业的综合竞争力最后输出可视化的对比结果。项目本身不复杂但数据口径、清洗规则、算法设计这些环节每一个都藏着不少容易翻车的细节。1. 择校难的根源和我最终锁定的分析框架1.1 为什么最终选择自建分析系统择校决策最痛苦的地方在于信息不对称。公开渠道能查到的数据虽然多但格式五花八门有的公告只给了总分线有的给了总分线和单科线还有的连报考人数都不公布。把这些零散信息整理成能横向对比的表格本身就是一个数据工程问题。我最早试过用 Excel 手工整理但数据一多就暴露了两个问题一个是不同年份的同一专业可能改名VLOOKUP 匹配经常断另一个是没法做动态权重计算比如今年更看重报录比就要重新拉一整套公式。后来干脆用 Python 把整个流程自动化数据落库之后想怎么切维度、加权重都只是改几行代码的事。自建系统的另一个优势是可复用。每年初试成绩出来后只需要把当年的计划数、报考人数、分数线填入数据库整套分析流程就能直接重跑一遍。帮别人做择校建议时也不用每次都从头翻公告直接查库、出图、导报告效率提升非常明显。1.2 核心分析维度哪些数据最能影响择校决策分析维度不是越多越好而是要在“数据可得性”和“决策价值”之间取平衡。我最终锁定了六个核心维度招生计划数统考名额直接决定竞争烈度名额少但报考热度高的专业要特别警惕。报考人数和计划数一起构成报录比是衡量竞争程度最关键的数据。复试分数线含单科线总分线反映录取门槛单科线则经常成为卡人利器。拟录取人数实际录取人数会和计划数有出入扩招或缩招都要看这个值。专业热度报考人数除以计划数的比值热度越高竞争越激烈。院校层次用“双一流/非双一流”“985/211/普通院校”这种梯度做粗略分层方便用户先筛学校再比专业。除了这些硬指标我还把“分数波动系数”也加进了分析维度。计算方法很简单取某个专业近三年的复试线标准差标准差越大说明分数线越不稳定择校风险也越高。这个指标在传统择校建议里很少有人提但实际参考价值很高。1.3 数据边界非官方数据的处理原则必须说实话很多关键数据在公开渠道里存在严重缺失。比如部分院校不公布报考人数或者只公布“进入复试人数”口径千差万别。系统不可能解决所有数据缺失问题但处理缺失的方式会影响分析结论的可靠性。我的处理原则有三个第一能从官方公告、招生目录里拿到的数据优先采集原始值并保留来源标记第二确实拿不到的字段宁可空缺也不瞎编在分析时单独归类第三为了演示和测试整套系统我会生成一份基于规则的模拟数据这份数据在代码里会被明确标记为is_generated 1保证真实数据和模拟数据永远不会混用。这样做的原因很现实。如果你用编造的数据训练出一个看似精确的推荐结果用在实际择校上就是误导。把数据边界写清楚反而让系统更有可信度。2. 数据从哪来采集、清洗与模拟数据生产2.1 数据源取舍与统一格式考研数据的公开来源主要是两类一类是各院校研招网发布的招生目录字段相对规范另一类是历年复试名单、拟录取名单以及招生简章里的说明文字格式简陋且口径混乱。采集环节我先不做全自动爬虫而是先把公开数据批量整理成 CSV 模板再让导入脚本读入数据库。这里有个关键经验不要把采集和分析写在一个脚本里。采集是易变的网站的公告形式每年都可能改版而分析逻辑相对稳定。先落成 CSV 再导入既方便人工校对又能让采集脚本挂了之后不影响后续环节。CSV 模板的字段我设计成统一口径字段名含义示例school_name院校名称某大学major_name专业名称以招生目录为准计算机科学与技术year招生年份2024plan_count统考招生计划数42apply_count报考人数316retest_line复试总分线349subject_line单科线英语/政治/业务课50/50/90admit_count最终拟录取人数45is_generated是否模拟数据0用统一模板的另一个好处是就算某个年份数据字段有出入清洗时也能一眼看出问题。2.2 清洗规则专业名称归一化与缺失值处理清洗是整个系统里最枯燥但最不能省的环节。我踩得最深的坑是跨年份专业名称不一致。同一专业某年叫“计算机技术”下一年叫“电子信息计算机技术方向”再下一年干脆拆成两个专业代码。如果不做归一化时间序列分析完全没法跑。我的做法是建一张专业别名映射表在导入阶段做三件事去掉专业名称里的括号注释和方向说明提取主干名称。把明显指代同一专业的别名归并例如“计算机技术”和“计算机技术方向”归为“计算机技术”。归一化后保存major_normalized字段所有分析都基于这个字段而不是原始名称。缺失值处理也很有讲究。复试线缺失时我采用“保留空值并用缺失标记参与分析”的方式而不是用均值填充。原因是分数线受当年试题难度影响极大跨年份均值填充会失真。对于报考人数缺失的年份在计算报录比时直接跳过该年并同步减少参与计算的年份数保证比值口径一致。2.3 一套可复制的模拟数据脚本演示系统时不能只有空库但真实数据又涉及来源问题所以模拟数据脚本就成了项目的重要组成部分。我用 Python 内置的sqlite3加少量随机逻辑生成六百多条记录覆盖五十所模拟院校、三十个常见专业时间跨度为三年。模拟脚本的核心代码逻辑如下import sqlite3 import random random.seed(2024) schools [f模拟院校{chr(65 i)} for i in range(50)] majors [计算机技术, 软件工程, 电子信息, 机械工程, 应用统计] conn sqlite3.connect(school_data.db) cur conn.cursor() for year in range(2022, 2025): for school in schools: for major in majors: plan random.randint(10, 60) apply int(plan * random.uniform(3, 12)) line random.randint(330, 390) admit int(plan * random.uniform(0.9, 1.3)) cur.execute( INSERT INTO admissions (school_name, major_name, year, plan_count, apply_count, retest_line, subject_line, admit_count, is_generated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1), (school, major, year, plan, apply, line, f{random.randint(45,60)}/{random.randint(45,60)}/{random.randint(90,120)}, admit) ) conn.commit() conn.close()这种生成方式的好处是数据分布接近真实情况热门专业报录比偏高、理工科总分线集中在 330 到 390 之间、部分院校扩招导致拟录取大于计划数。虽然字段是随机生成的但用于验证分析流程和熟悉代码库完全够用。3. 数据库设计表结构决定分析的天花板3.1 核心表设计与关系数据库设计上我没有过度建模核心就四张表院校表、专业表、招生录取事实表、专业别名映射表。最简单也最不容易出错。CREATE TABLE schools ( school_id INTEGER PRIMARY KEY, school_name TEXT NOT NULL UNIQUE, level TEXT, region TEXT ); CREATE TABLE majors ( major_id INTEGER PRIMARY KEY, major_name TEXT NOT NULL, major_normalized TEXT NOT NULL ); CREATE TABLE admissions ( id INTEGER PRIMARY KEY, school_id INTEGER REFERENCES schools(school_id), major_id INTEGER REFERENCES majors(major_id), year INTEGER NOT NULL, plan_count INTEGER, apply_count INTEGER, retest_line INTEGER, subject_line TEXT, admit_count INTEGER, is_generated INTEGER DEFAULT 0 ); CREATE TABLE major_aliases ( alias_name TEXT PRIMARY KEY, normalized_name TEXT NOT NULL );设计上最关键的决策是把“专业”单独拆表而不是直接把专业名称塞进事实表。这样做的意义在于专业改名、方向拆分都是常态单独维护映射关系之后历史数据的归并就变得非常容易。院校表单独拆出来则是为了冗余level、region这些相对稳定的属性避免在事实表里重复存储。3.2 为什么选 SQLite而不是一上来就用 MySQL这是个经常被问的问题。系统的数据量级在几千条以内单机跑分析连并发写入的需求都没有。SQLite 完全够用而且有三个实际好处一是 Python 内置支持零安装、零配置二是数据库文件可以直接随项目交付别人拿到源码和.db文件就能跑三是便于测试出问题直接删文件重建。之前帮某评估小组做跨平台系统时用过 MySQL功能确实强但引入服务端就意味着部署成本上升。单体数据分析工具用 SQLite 是合理的工程判断等数据量真的到几十万条或者需要多人同时写入时再去迁移也不迟。在文章里我也明确写了这个选型理由避免后来者觉得设计保守。3.3 索引与查询优化思路几千条数据建索引看起来是小题大做但分析场景下经常要做跨年份过滤适度的索引还是能带来体感上的差异。我在admissions表上建了一个三字段复合索引CREATE INDEX idx_admissions_year_school_major ON admissions (year, school_id, major_id);这个索引覆盖了“某年某校某专业”这个最常见的查询路径而且因为是复合索引按年份单独过滤时也能走索引前缀。另一个优化是把经常做等值匹配的major_normalized字段加普通索引比全表扫描匹配快很多。实际测试中在几千条数据量下单条查询从十几毫秒降到一两毫秒虽然绝对数值不大但跑批量分析时累计时间差就出来了。4. 核心分析算法的实现细节4.1 报录比、复试线走势与招生计划波动报录比是最常用的竞争指标但口径一定要统一。我把它定义为“报考人数 / 最终拟录取人数”。为什么不直接用计划数因为很多院校存在扩招用拟录取人数更贴近真实竞争程度。def calculate_competition(row): if row[admit_count] and row[admit_count] 0: return round(row[apply_count] / row[admit_count], 2) elif row[plan_count] and row[plan_count] 0: return round(row[apply_count] / row[plan_count], 2) return None复试线走势的计算稍微复杂一点。直接取三年的线做折线图没问题但要判断一个专业是不是“大小年”我引入了差值波动系数import statistics def line_volatility(lines): if len(lines) 2: return 0.0 return round(statistics.stdev(lines), 1)波动系数越大说明分数线波动越剧烈择校时需要更高的风险承受能力。类似地招生计划的波动也用标准差衡量计划数每年剧烈变化的专业往往存在政策调整或学科重组我会在分析报告里给它加风险提示标签。4.2 专业冷热度与院校竞争力的量化专业热度我单独定义了一个“热度指数”热度 报录比标准化值再映射到 0 到 100 的区间。标准化用的是最大最小归一化而不是 Z-score。原因是报录比分布通常右偏少数热门专业的极端值会把其他专业压得毫无区分度最大最小归一化配合截断处理反而更直观。院校竞争力的量化更有意思。我采用“层次权重 专业实力 竞争烈度”三部分合成。层次权重来自一个配置表比如重点建设高校基础分为 80其他院校为 60专业实力用“是否拥有重点学科”这类二值字段来加分竞争烈度则用热度指数的倒数参与加权因为竞争越小上岸概率越高。这个评分模型刻意保持透明每一项都能反推回原始数据。我不建议用复杂的机器学习模型来做择校推荐因为样本量撑不起黑盒模型而且用户需要对结果有解释权。一个可解释的加权评分远比一个无法回溯的预估分可靠。4.3 基于权重评分的择校推荐逻辑系统允许用户输入偏好权重比如更看重“上岸概率”就把竞争烈度权重调高更看重“院校层次”就把层次权重调高。推荐引擎的核心是一个加权求和函数def recommend_schools(df, weights): scores {} for _, row in df.iterrows(): score ( weights.get(level, 0) * row[level_score] weights.get(major, 0) * row[major_score] weights.get(chance, 0) * (100 - row[hot_index]) weights.get(volatility, 0) * (100 - row[volatility] if row[volatility] else 0) ) scores[(row[school_name], row[major_name])] round(score, 2) return sorted(scores.items(), keylambda x: x[1], reverseTrue)[:10]这里的核心是把“竞争烈度”和“波动风险”都转成“越低越好再取反”的分数形式这样所有维度都是数值越大越推荐权重调整的语义就统一了。最终输出前十个“院校-专业”组合并附带每一项的得分构成方便用户检查推荐原因。实测下来这套逻辑比单纯看分数线靠谱得多。它会自动把“报录比极高但分数线虚高”的热门专业降权也会把“分数线不高但报录比极低”的隐形热门专业暴露出来这两个方向恰恰是手工择校最容易看走眼的地方。5. 可视化与结果导出让分析结果真正可用5.1 趋势图和对比图的绘制要点数据入库后可视化是用户感知最直观的一层。我用了最成熟的matplotlib方案没有引入太重的前端框架。绘图部分有两个最容易踩的坑一个是中文乱码一个是年份刻度挤成一团。中文乱码的标准解法是显式设置字体import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Noto Sans CJK SC, Microsoft YaHei] plt.rcParams[axes.unicode_minus] False年份刻度问题更隐蔽。如果横轴是 2022、2023、2024 三个年份默认刻度算法可能会只显示其中一两个。我直接用plt.xticks()强制指定plt.figure(figsize(8, 4)) plt.plot(years, lines, markero, label复试线) plt.bar(years, apply_counts, alpha0.3, label报考人数) plt.xticks(years) plt.legend() plt.title(f{school} - {major} 近三年趋势) plt.tight_layout() plt.savefig(trend.png, dpi150)这个组合图同时展示复试线和报考人数能一眼看出“分数线上升是否伴随报考人数激增”比单独画一条线信息量大得多。类似的对比图还有报录比横向柱状图、热度指数气泡图气泡大小代表招生计划数位置代表热度排名非常适合多院校同专业对比。5.2 一键导出分析报告分析结果如果只停留在屏幕上很难给非技术背景的人使用。我在项目里做了两层导出第一层是把结构化分析结果导出为 CSV方便后续在 Excel 里继续加工第二层是用python-docx将图表和分析结论拼成一份 Word 报告自动带上标题、表格和图片。导出模块的设计原则是“可复用模板 数据填充”。报告框架由一份蓝图字典定义包含章节标题、要点、图片路径、表格标题。每次分析结束后系统把结果填入蓝图生成新文件。这样即使换了分析对象报告结构也保持一致不会出现这次有表格、下次没表格的混乱。报告里我会强制加入一段“数据说明”注明各字段的统计口径、缺失数据处理方式、数据来源类型。这个细节虽然不起眼但在把报告分享给别人时能省掉大量解释工作。5.3 出图时那几个让我返工的环境问题Matplotlib 出图过程中我遇到过几次比较崩溃的细节顺带记录在这里。第一是.savefig()截断坐标轴标签。某些统计图的标签文字偏长保存时底部文字被裁掉一半。解决办法是在保存前调用plt.tight_layout()或者调整bbox_inchestight。第二是气泡图的图例大小比例问题。气泡面积的映射如果用半径而不是面积视觉上会形成错误的数量对比。绘制气泡图时必须使用s参数并传入面积值而不是直径值。第三是中文引号被当成注释符号的问题。在部分编辑器和 Python 版本中代码文件如果是 UTF-8 无 BOM且字符串里用了中文引号会有极小概率触发奇怪的解析问题。这个属于玄学坑规避方式就是调用 Excel 里的文本工具统一替换掉全角引号或者保存文件时确保编码一致。6. 源码组织、文档交付与二次开发6.1 项目目录设计与模块职责一个“源码数据库文档”的完整交付物目录结构必须能让人一眼看懂。我的项目顶层结构如下project/ ├── app.py # 主入口命令行交互 ├── requirements.txt # 依赖清单 ├── README.md # 项目说明快速上手 ├── data/ │ ├── raw/ # 原始CSV采集数据 │ └── generated/ # 模拟数据脚本 ├── database/ │ └── school_data.db # SQLite数据库文件 ├── analysis/ │ ├── clean_data.py # 清洗与导入 │ ├── competition.py # 报录比与热度计算 │ └── recommend.py # 权重推荐算法 ├── report/ │ ├── charts/ # 生成的图表 │ └── output/ # 导出的分析报告 └── docs/ ├── data_dictionary.md # 数据字典 └── usage_guide.md # 使用文档每个模块都保持单一职责clean_data.py只负责数据导入和清洗competition.py只负责计算指标recommend.py只负责组合评分。这样做的直接好处是任何一个模块出问题都不需要排查其他模块而且新增功能时改动范围可以控制在单一文件内。6.2 文档里哪些内容必须写清楚交付文档最忌讳的是只写“项目简介”和“安装步骤”。真实使用者拿到的文档至少需要涵盖三类内容。数据字典是第一位的。每个字段的含义、取值范围、缺失标记、生成方式都要写清楚。例如subject_line字段存的是“英语/政治/业务课”的分数线组合如果文档不说明格式后来者很可能把它当成三个独立字段来处理。使用文档里重点写“如何更新数据”。刚拿到项目的人最可能的操作不是跑分析而是导入自己手头的新数据。因此需要把 CSV 模板的字段顺序、导入脚本的命令行参数、导入后如何校验行数写全面。交付目录里还必须有一份FAQ.md把导入时常见报错、中文乱码、跨平台路径分隔符等问题收录进去。这些内容看起来琐碎但真实使用中用户的疑问往往就集中在这几个点上。6.3 扩展示例新增“调剂人数”分析维度的完整流程为了验证项目的可扩展性我用“新增调剂拟录取人数”这个维度做了完整的演练流程过程可以参考。第一步在 CSV 模板和数据库表中都增加adjust_admit_count字段。第二步在清洗脚本里加入该字段的非空校验允许缺失但纳入统计。第三步在分析模块中新增一个计算函数输出“调剂比例 调剂录取人数 / 总拟录取人数”。第四步在评分模型中加入“调剂友好度”权重调剂比例较高的专业适当加分。第五步在报告模板里增加这一指标的可视化图块。整个流程走下来改动覆盖了采集、存储、分析、展示四个层次但没有任何一个环节需要重写既有代码。这大概就是模块化设计带来的最大红利——扩展功能变成填格子而不是动地基。我实际把这套系统用在一个小范围的择校咨询场景里前后帮十几位同学跑过分析。最明显的感受是系统的价值不在于“替你做决定”而在于帮你把模糊的直觉变成可比较的数字。个人的一个小建议是拿到推荐结果之后先不看排名前十的组合而是重点看每一项得分的构成分数背后的逻辑往往比分数本身更有参考意义。如果让我重新做一遍这个系统我会从一开始就把每年更新数据做成半自动流程而不是等到第二年手动补录这算是这次项目里我收获最大的一条教训。
阅读完成 · 觉得有帮助?
咨询建站