news 2026/9/19 15:14:29

用Python解析PDF录取数据:清洗入库与趋势分析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python解析PDF录取数据:清洗入库与趋势分析

简介:杭州师范大学2020-2024年在上海各专业最低录取分数及位次数据,面向高考考生、家长及志愿规划者,用于对比历年分数线与位次,评估专业组录取难度和报考热度。资源包含一个PDF文件,大小约15KB,内容涵盖各专业组的选考科目要求,如物理必选、物/化/生3选1、物/化/地3选1等,并记录了各专业最低分、最低位次、报考人数及批次线差,便于考生按选考科目精准匹配。已有87人学习。从数据中可看出计算机、电子信息等专业在不同年份的分数波动,位次分布也能帮助考生判断自身竞争力,制定更稳妥的志愿梯度。同时,文件中还列出了不限选考科目的专业组,如法学、护理学等,方便不同选科组合的考生参考。建议结合最新招生简章与官方政策使用,以应对政策和专业设置的可能调整,提高填报科学性。

1. 用 Python 把“杭州师范大学在上海 2020-2024 各专业最低录取分数及位次表”变成可分析的数据

招生录取数据最麻烦的形态就是 PDF。杭州师范大学在上海 2020 到 2024 各专业最低录取分数及位次表,本质上是一张跨五年、按专业纵向铺开的时间序列表格,但装进了 PDF 外壳里。你要做的是把这张表从“能看”变成“能算”:筛选某个专业五年的分数走势、对比不同专业的位次波动、甚至预测下一年的大致门槛。直接打开 PDF 阅读器翻页是最低效的做法——文本没法直接进 Excel、SQL 和可视化工具。本文按数据工程师处理此类半结构化文档的常规路线走一遍:先建模,再解析,然后清洗入库,最后做趋势分析。全程用可复现的 Python 代码和 SQL 语句,你只需要把文件路径换成自己手头那份 PDF 即可。

2. 录取数据建模:先定字段类型,再写解析脚本

拿到这份 PDF,第一件事不是写正则,而是设计数据模型。只有把“分数”“位次”“年份”“专业”和“招生地区”这五个维度拆清楚,后续的解析和入库才不会返工。

2.1 五元组结构设计

对于这类高校分省录取数据,最稳定的记录单位是“某年-某地区-某专业-最低分-最低位次”。地区字段虽然在整个 PDF 里只有一个值(上海),但保留它可以让同一套脚本直接复用到北京、浙江等其他省份的同类 PDF 上,属于一次性设计、多次受益的做法。

字段类型的选择对后续查询影响很大。年份用 INTEGER,分数用 SMALLINT(上海高考总分 660,最低分范围在 250 到 600 之间,SMALLINT 足够),位次用 INTEGER。专业名称虽然表面上看起来是文本,但在清理阶段必须做标准化——比如同一专业在不同年份可能写成“计算机科学与技术”和“计算机与科学技术”,这种差异会在 JOIN 时丢数据。

字段名类型说明
provinceTEXT招生地区,本场景固定为上海
yearINTEGER录取年份,2020-2024
majorTEXT专业名称,需做全角/半角归一化
min_scoreSMALLINT最低录取分数
min_rankINTEGER最低录取位次,数值越小排名越靠前

建好这个五元组模型后,解析脚本的输出直接映射到 pandas DataFrame,然后落成 CSV 作为中间层。不要把解析结果直接写入数据库,中间留一层文本格式方便人工抽查。

2.2 环境准备与 PDF 解析库选型

PDF 解析没有银弹。PyMuPDF(fitz)擅长按坐标提取文本块,适合表格线完整、排版规则的文档;pdfplumber 在识别表格结构上更强,能直接调用 find_tables 抽取单元格,但速度慢不少。对于“杭州师范大学在上海 2020-2024 各专业最低录取分数及位次表”这类跨页表格,我的做法是先用 PyMuPDF 扫一遍文本流,如果发现表格线噪声太大再切 pdfplumber 兜底。

pip install pymupdf pdfplumber pandas sqlite3

安装时注意 PyMuPDF 的导入名是 fitz 而不是 pymupdf,这是新手最容易卡住的地方。pandas 不是解析的必需品,但用它做字段类型强转和空值清洗比纯 Python 循环快十倍以上。sqlite3 是 Python 内置模块,不需要单独安装,导入即用。

3. 用 PyMuPDF 按坐标行分割表格,正则提取分数与位次

现在进入解析的核心环节。这份 PDF 的难点不在页数多,而在表格跨页后表头重复出现、以及合并单元格导致某些行只有三个字段但理论上应该有四列。坐标行切分是解决这类问题最稳的方式。

3.1 读取 PDF 并按 Y 坐标聚合文本行

PDF 的内在排版模型是“块-行-跨度”三层结构,文本在页面上的位置由坐标决定。大多数人用 get_text("text") 直接拼字符串,但那种方式丢掉坐标信息,遇到跨栏表格就会串行。正确做法是提取每行的 Y 轴坐标,把 Y 坐标相近的跨度拼成一行,这样无论 PDF 内部怎么排序,输出顺序都符合人类阅读习惯。

import fitz import re import pandas as pd doc = fitz.open("杭州师范大学在上海2020-2024各专业最低录取分数及位次表.pdf") raw_lines = [] for page in doc: blocks = page.get_text("dict")["blocks"] # 按 Y 坐标分组:同一行文本的 Y 坐标基本相同 line_map = {} for block in blocks: for line in block.get("lines", []): y0 = round(line["bbox"][1], 1) # 行顶部的 Y 坐标 text = "".join(span["text"] for span in line["spans"]) if y0 not in line_map: line_map[y0] = [] line_map[y0].append(text) # 对每个页面内的行按 Y 轴排序 for y0 in sorted(line_map.keys()): raw_lines.append(" ".join(line_map[y0])) doc.close()

这段代码的核心逻辑都写在注释里了。get_text("dict") 返回嵌套字典,包含页面内所有文本块的结构化信息和坐标,比 get_text("text") 更适合做行级还原。y0 坐标做 round 处理是因为 PDF 渲染时同一行文本的 Y 坐标可能有 0.1 级的浮点误差,四舍五入到一位小数就能把同一个逻辑行的文本归并到一起。这里不按 X 坐标切列的原因是分数和位次之间可能有空白列,直接按 X 分组容易把“专业名称”和“最低分”分成两个独立列,后续还得手动对齐。

3.2 正则匹配年/专业/分数/位次四元组

行文本还原后,需要从一段段文本中抽取出结构化字段。典型的行长这样:“2024 计算机科学与技术 558 12345”,但实际文档中可能有“2024年”和“计算机科学与技术(师范)”这类变体。正则要同时兼容单位后缀和括号备注。

pattern = re.compile(r"(20[2-4]\d)\s*年?\s*([\u4e00-\u9fa5A-Za-z()()]+?)\s*?(\d{3})\s+(\d{3,5})") parsed_rows = [] for line in raw_lines: match = pattern.search(line) if match: year = int(match.group(1)) major = match.group(2).strip() score = int(match.group(3)) rank = int(match.group(4)) # 分数合理性校验:上海高考满分 660 if 200 <= score <= 660: parsed_rows.append({ "province": "上海", "year": year, "major": major, "min_score": score, "min_rank": rank }) df = pd.DataFrame(parsed_rows) df.drop_duplicates(inplace=True) df.to_csv("hangzhou_sha_2020_2024.csv", index=False, encoding="utf-8-sig")

正则的匹配逻辑:年份限定在 2020 到 2024 之间,专业名只匹配中文、英文字母和括号,分数固定三位数(因为上海总分 660,最低录取分在 260 到 580 之间),位次是三到五位数字。这里的 \s*? 用了非贪婪模式,防止专业名的前缀字符被吞掉。drop_duplicates 是必要的,因为跨页表格会在页首重复上一次的末行,不加去重会导致同一记录出现两次。

输出 CSV 时用 encoding="utf-8-sig" 而不是 utf-8,是为了让 Excel 直接打开时中文不乱码,这是 Windows 环境下给非技术人员交付文件的习惯做法。

3.3 兜底方案:pdfplumber 处理无表格线文本

不是所有招生 PDF 都有清晰表格线。有些文件是从网页直接打印的,表格线消失,只剩文本块在页面上浮动。这种场景下 PyMuPDF 的坐标行分割会把两列文本拼到同一行。识别特征是:同一行里出现多个三位数,且无法用年份正则定位。

import pdfplumber with pdfplumber.open("杭州师范大学在上海2020-2024各专业最低录取分数及位次表.pdf") as pdf: for page in pdf.pages: tables = page.extract_tables({"vertical_strategy": "lines"}) for table in tables: for row in table: if row and isinstance(row[0], str) and re.search(r"20[2-4]\d", row[0]): # row 结构: [年份, 专业名称, 最低分, 最低位次] print(row)

pdfplumber 的 extract_tables 返回行列二维矩阵,比坐标分割更直接。vertical_strategy 设为 lines 是指定按可视线条识别表格边界,如果 PDF 没有线条就换 “text” 策略按文本对齐推断。注意这里的参数名是 vertical_strategy,对应的 horizontal_strategy 控制横向切分,两个都要设置才能得到完整的网格。

4. 数据清洗与 SQLite 入库:合并单元格和全角空格是最大的坑

解析只是拿到原始数据,清洗才是真正花时间的地方。这类跨五年的 PDF 最典型的坑有三个:合并单元格导致年份缺省、全角空格混入专业名、以及同一专业在不同年份改名。逐一看怎么处理。

4.1 合并单元格的纵向下拉填充

招生 PDF 为了缩减篇幅,通常只在每年第一行写明年份,后续行用空白代替。解析脚本拿到的结果是:2024 计算机科学与技术 558,下一行直接是软件工程 552,年份是空的。这是一个典型的“向前填充”问题。

# 用 fillna(method="ffill") 按顺序向下填充缺失的年份 df["year"] = df["year"].fillna(method="ffill").astype(int) # 专业名校验:把全角空格和特殊字符统一替换为半角空格 df["major"] = df["major"].str.replace("\u3000", " ").str.replace("(\s)+", " ", regex=True).str.strip() # 位次合理性兜底:位次不可能为 0 或 99999 df = df[(df["min_rank"] > 0) & (df["min_rank"] < 60000)]

ffill 是 pandas 的向前填充方法,原理是把上一个非空值复制到后续空值位置,直到遇到下一个非空值重新开始。这里的逻辑假设是“表格按年份排序且每年连续”,如果解析结果乱序,必须先 sort_values("year") 再来填充,否则会串年份。位次上限 60000 是对上海考生的估算上限——上海每年高考人数在 5 万左右,位次最大也就五位数。

4.2 用 SQL 做一致性校验

数据清洗之后,入库之前加一道 SQL 校验,专业名称拼写错误或分数跳变都能在这里显形。

-- 一致性校验:同一专业在相邻年份最低分差异超过 50 分的视为可疑 SELECT a.major, a.year, a.min_score, b.year AS next_year, b.min_score AS next_score FROM admissions a JOIN admissions b ON a.major = b.major AND a.year + 1 = b.year WHERE ABS(b.min_score - a.min_score) > 50 ORDER BY a.major;

这个查询的作用是找出分数异常波动的记录。正常情况下一个专业在上海的录取分数逐年波动在正负 20 分以内,超过 50 分基本意味着专业改名、停招后复招或者解析错误。算出结果后逐条人工核对,比盯着几百行 CSV 看效率高得多。

# 新建 SQLite 数据库并导入 CSV sqlite3 admissions.db .mode csv .import hangzhou_sha_2020_2024.csv admissions

导入完成后执行 SELECT COUNT(*) FROM admissions,总数应该是 5 倍于单年专业数(除非某年确实有专业未招生)。如果少了一部分,回到解析步骤检查是否有行被正则漏掉,多半是专业名里带了“(中外合作办学)”这类超长备注导致正则括号匹配失败。

5. 用 SQL 和 matplotlib 做五年趋势分析并验证数据质量

有了 SQLite 里的结构化数据,分析就是纯写查询的事。这里会给一个完整的分析流程:先算专业分数涨跌,再用 matplotlib 画双轴趋势图,最后抛开可视化,用三条 SQL 验证整份数据的一致性。

5.1 找出五年间涨幅最大的专业

SELECT major, MAX(CASE WHEN year = 2024 THEN min_score END) AS score_2024, MAX(CASE WHEN year = 2020 THEN min_score END) AS score_2020, MAX(CASE WHEN year = 2024 THEN min_score END) - MAX(CASE WHEN year = 2020 THEN min_score END) AS score_gap FROM admissions WHERE year IN (2020, 2024) GROUP BY major HAVING score_2020 IS NOT NULL AND score_2024 IS NOT NULL ORDER BY score_gap DESC;

这条查询用条件聚合把 2020 和 2024 两年的分数拉平到同一行,再算差值。核心技巧是 MAX(CASE WHEN ...) 代替 WHERE 过滤后进行列转行,这也是处理“宽表化”最标准的姿势。HAVING 里的两个 IS NOT NULL 条件把只在一侧有数据的专业过滤掉,这种专业如果出现在结果里,说明某年停招但文件里还留着空行,是数据质量问题。

5.2 双轴趋势图的正确画法:位次轴必须反转

分数和位次量纲不同,必须用双 Y 轴。但位次有个特殊性质:数值越小代表录取门槛越高,所以位次轴要 invert_yaxis 反转刻度方向,才能让两个指标呈现一致的“向上走高”趋势,方便阅读。

import matplotlib.pyplot as plt import pandas as pd import sqlite3 conn = sqlite3.connect("admissions.db") df = pd.read_sql_query( "SELECT * FROM admissions WHERE major = '计算机科学与技术' ORDER BY year", conn ) fig, ax1 = plt.subplots(figsize=(10, 6)) ax1.plot(df["year"], df["min_score"], "o-", color="#d33", label="最低分") ax1.set_ylabel("最低录取分数", fontsize=12) ax1.set_ylim(400, 600) ax2 = ax1.twinx() ax2.plot(df["year"], df["min_rank"], "s--", color="#2563eb", label="最低位次") ax2.set_ylabel("最低位次", fontsize=12) ax2.invert_yaxis() # 位次越小越好,反转轴保持方向语义一致 plt.title("杭州师范大学 计算机科学与技术 上海 2020-2024", fontsize=14) plt.xticks(df["year"]) plt.tight_layout() plt.savefig("trend_cs.png", dpi=200)

matplotlib 的 twinx 生成的 ax2 与 ax1 共享 X 轴,但拥有独立的 Y 轴。注意 ax2 的刻度范围和 ax1 完全独立,所以两条线在视觉上的交点位置不代表数值相等,只能在报告里说明各自的物理含义。参数 dpi=200 是为了印刷清晰度,如果只是屏幕看图 dpi=150 就够了。

5.3 数据质量验证:三条 SQL 让异常无处遁形

最后花一分钟验证整张表的完整性。这三条查询不复杂,但我每一次处理招生数据都会跑一遍,因为它们能暴露解析阶段最难发现的跨页丢失问题。

-- 检查每年专业数量是否一致 SELECT year, COUNT(DISTINCT major) AS major_cnt FROM admissions GROUP BY year ORDER BY year; -- 检查位次为空或分数为 0 的非法记录 SELECT * FROM admissions WHERE min_rank IS NULL OR min_score <= 0; -- 用 2020 和 2021 两年的重合专业数验证 JOIN 无损 SELECT COUNT(*) AS matched_years FROM ( SELECT major FROM admissions WHERE year = 2020 INTERSECT SELECT major FROM admissions WHERE year = 2021 );

第一条查询的结果如果是每年专业数相差超过两个绝对值,说明某年的专业被正则漏掉或重复计数,需要回头检查 PDF 里是否有“本页无招生计划”之类的跳页说明。第三条查询的 INTERSECT 是 SQL 标准集合操作,在 SQLite 里同样支持,结果是两年都出现的专业数量。这个值应该大于等于总专业数的 80%,如果远低于这个比例,大概率不是专业大量变动,而是专业名称写法不一致(比如“数学与应用数学”和“数学与应用数学(师范)”被当成两个不同专业),此时需要对专业列做进一步的别名映射。我不打算把数据可视化这块收束成一个常规意义的结尾。趋势图和校验查询本身比任何结论性的总结都有力,当你跑完这三条 SQL,观察到的表格数据是否可信,心里自然有数。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 15:14:25

带本地存储的POE温湿度记录仪:STM32+SNMP+审计报表设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 15:11:07

Qt for MCUs 2.11 LTS深度解析:ESP32-S3与RA8D1地图渲染优化实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 15:04:44

MATLAB语音信号处理实战:从频谱分析到滤波器设计

简介&#xff1a;本资源是一份面向高校电子信息类专业本科生及DSP初学者的MATLAB数字信号处理实践指南&#xff0c;聚焦语音信号采集、频谱分析与IIR/FIR滤波器设计等核心实验任务。PDF文档完整覆盖课程设计全流程&#xff1a;从wavrecord/wavplay等音频函数使用、FFT频谱可视化…

作者头像 李华
网站建设 2026/9/19 15:04:40

LLVM项目深度解析:从核心架构到源码构建与实战优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华