ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

用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-2024majorTEXT专业名称需做全角/半角归一化min_scoreSMALLINT最低录取分数min_rankINTEGER最低录取位次数值越小排名越靠前建好这个五元组模型后解析脚本的输出直接映射到 pandas DataFrame然后落成 CSV 作为中间层。不要把解析结果直接写入数据库中间留一层文本格式方便人工抽查。2.2 环境准备与 PDF 解析库选型PDF 解析没有银弹。PyMuPDFfitz擅长按坐标提取文本块适合表格线完整、排版规则的文档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(inplaceTrue) df.to_csv(hangzhou_sha_2020_2024.csv, indexFalse, encodingutf-8-sig)正则的匹配逻辑年份限定在 2020 到 2024 之间专业名只匹配中文、英文字母和括号分数固定三位数因为上海总分 660最低录取分在 260 到 580 之间位次是三到五位数字。这里的 \s*? 用了非贪婪模式防止专业名的前缀字符被吞掉。drop_duplicates 是必要的因为跨页表格会在页首重复上一次的末行不加去重会导致同一记录出现两次。输出 CSV 时用 encodingutf-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(r20[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(methodffill) 按顺序向下填充缺失的年份 df[year] df[year].fillna(methodffill).astype(int) # 专业名校验把全角空格和特殊字符统一替换为半角空格 df[major] df[major].str.replace(\u3000, ).str.replace((\s), , regexTrue).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(最低录取分数, fontsize12) ax1.set_ylim(400, 600) ax2 ax1.twinx() ax2.plot(df[year], df[min_rank], s--, color#2563eb, label最低位次) ax2.set_ylabel(最低位次, fontsize12) ax2.invert_yaxis() # 位次越小越好反转轴保持方向语义一致 plt.title(杭州师范大学 计算机科学与技术 上海 2020-2024, fontsize14) plt.xticks(df[year]) plt.tight_layout() plt.savefig(trend_cs.png, dpi200)matplotlib 的 twinx 生成的 ax2 与 ax1 共享 X 轴但拥有独立的 Y 轴。注意 ax2 的刻度范围和 ax1 完全独立所以两条线在视觉上的交点位置不代表数值相等只能在报告里说明各自的物理含义。参数 dpi200 是为了印刷清晰度如果只是屏幕看图 dpi150 就够了。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观察到的表格数据是否可信心里自然有数。本文还有配套的精品资源点击获取
返回列表