做数据分析这些年,我整理过不少公开数据集,但如果说哪个最难“攒”,全国民用运输机场生产统计公报绝对能排进前三。它不是一份文件,而是从2006年到2024年横跨19年的年度报告,每一年都有自己的排版习惯、统计口径甚至单位写法,光是把这些数据从公报里完整、准确地抠出来,就足够让一个熟练的数据工程师忙上两三天。这篇博文把我实际跑通的完整流程记录下来,从数据源定位、PDF解析、字段清洗到最终的核验输出,适合准备做民航数据研究、机场发展分析或交通运输类数据产品的朋友直接参考。
1. 公报的数据构成与表结构设计
1.1 先搞清楚公报里到底有什么
全国民用运输机场生产统计公报由中国民航局每年发布,核心内容分两块:一块是全国的总体情况,另一块是所有运输机场的分项生产数据。总体情况一般包括当年全国民用运输机场数量、旅客吞吐量、货邮吞吐量、起降架次等总量指标;分项数据则是按机场逐一列出的明细表,通常包含排名、机场名称、旅客吞吐量、货邮吞吐量、起降架次以及对应的同比增速。
在做数据获取之前,必须先弄明白这些字段的业务含义。旅客吞吐量指一个机场年度内进出港旅客的总数,货邮吞吐量指航空货邮的运输量,起降架次则是飞机的起飞降落次数。这三个指标是民航生产统计的核心“三件套”,也是后续几乎所有分析的基础。需要注意的是,不同年份的公报中,这些字段的表头写法会有差异,比如旅客吞吐量有时写成“旅客吞吐量(万人)”,有时只写“旅客吞吐量”,单位则可能藏在表头之外的说明里。
我见过不少人拿到公报后直接开始写爬虫,结果写了一半才发现连“一年有多少个机场需要抓取”都没确认清楚。这里建议第一步不要写任何代码,而是把2006到2024年的公报全部下载下来,人工浏览一遍目录和表格结构。这个看起来不起眼的动作,能为你省下后面大量的清洗时间。
1.2 19年的格式演变是第一道门槛
为什么说这是最难“攒”的数据集之一?因为2006年到2024年这19年间,公报本身的形态一直在变。早期年份的公报内容相对简略,网页版和PDF混杂,有些年份甚至只有总量数据和部分机场排名;到了2015年以后,公报逐渐规范化,PDF文档里会列明全部运输机场的明细数据,排版也从简单的纯文本表格变成带有复杂合并单元格、跨页表头的标准报表。
机场数量也在持续变化。2006年的时候,全国颁证民用运输机场大约140多个,到2024年底已经超过260个。这意味着每年的表格行数是不同的,不能固定按某个年份的模板去解析。更重要的是,有些机场因为吞吐量太小,在个别年份的公报中不单独列示,而是被归入“其他机场”或“合计”类目,这类数据在汇总时容易造成统计口径上的偏差。
还有一个常见的坑是公报的名称并不完全统一。有时候叫“民航机场生产统计公报”,有时候叫“全国民用运输机场生产统计公报”,个别年份还用“快报数据”“最终数据”这样的后缀。用脚本直接按关键词搜索文件名,很容易漏掉某个年份,所以人工确认一遍数据源是必要的。
1.3 目标表结构怎么设计
在做实际解析前,我建议先想清楚最终要的数据格式。对于这种多年份面板数据,最常见的设计有两种:宽表和长表。
宽表是把每一年作为一个字段列,适合对比单个机场的多年变化;长表则是按“年份+机场”为每一行,适合做整体分析和计算。我最终选的是长表结构,核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| year | int | 数据对应的年份 |
| airport_name | str | 机场名称,按当年公报口径 |
| rank | int | 当年旅客吞吐量排名 |
| passengers | float | 旅客吞吐量,统一为万人次 |
| passengers_yoy | float | 旅客吞吐量同比增速,百分比数值 |
| cargo | float | 货邮吞吐量,统一为吨 |
| cargo_yoy | float | 货邮吞吐量同比增速,百分比数值 |
| movements | int | 起降架次 |
| movements_yoy | float | 起降架次同比增速,百分比数值 |
主键就是(year, airport_name)。这个设计的好处是,无论你想做ranking变化、增长率计算,还是把长表pivot成宽表,都很方便。需要注意的是,机场名称必须保持当年公报的原始写法,不要在一开始就做名称统一,因为有些机场在不同年份的简称不一样,直接替换会导致主键冲突。名称的规范化应该放在清洗阶段的最后一步单独处理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据获取:定位、下载与PDF解析
2.1 数据源定位与文件收集
中国民航局官网的“行业统计”或“统计数据”栏目是公报的主要发布渠道。实际操作中,我不会直接去猜每个年份的URL,而是先在栏目页里人工翻一遍,确认2006到2024年这19份公报的入口都在。有些年份的公报可能藏在“通知公告”里,有些则在“年度数据”的二级页面,找不到是正常的,可以借助搜索引擎的站内搜索来辅助定位。
文件格式方面,近年的公报基本是标准PDF,可以直接解析;早年的公报有时只有网页HTML正文没有PDF附件。遇到这种情况,要么直接复制网页表格的数据,要么先检查网页是否有“打印版”“下载版”链接。我的经验是,把偶尔出现的HTML表格也统一存成HTML文件,和PDF一起放进raw目录,不要手工复制粘贴到Excel里,因为手工操作既慢又容易引入格式混乱。
下载完成后,建议做一个完整性检查脚本,至少确认每个年份都有文件、PDF能正常打开、页数不是0。我当时是在本地建了一个data/raw目录,文件统一命名为caac_airport_{year}.pdf,然后用一个小脚本批量读取PDF的页数和大小,检查哪些年份的文件有问题。
python复制import os
from pypdf import PdfReader
for year in range(2006, 2025):
path = f"data/raw/caac_airport_{year}.pdf"
if not os.path.exists(path):
print(f"{year}: 缺失")
continue
try:
reader = PdfReader(path)
size_kb = os.path.getsize(path) / 1024
print(f"{year}: {len(reader.pages)} 页, {size_kb:.1f} KB")
except Exception as exc:
print(f"{year}: 读取失败 {exc}")
这一步跑完,哪些年份需要重新下载就一目了然了。别跳过,我看到太多人解析到一半才发现某年文件损坏,回头补文件的成本远高于一开始做检查的成本。
2.2 PDF解析工具选型
PDF表格解析是这整个流程的技术核心,工具选得对不对直接影响后续清洗的工作量。主流的Python方案有三个:pdfplumber、camelot和tabula-py。
pdfplumber的优点是能精细控制解析策略,对文本型PDF效果很好,而且底层依赖轻;camelot的优点是表格线检测能力强,但同样对扫描版PDF无能为力;tabula-py底层依赖Java,部署相对繁琐,我一般不太推荐。就民航局的公报来说,文件基本都是文本型PDF而不是扫描件,所以pdfplumber是我实际选择的主力工具。
选择pdfplumber还有一个重要原因:它的extract_tables方法能返回边缘坐标,当表格线缺失导致列错位时,可以退回用坐标分组的方式重新组织单元格数据。这在应对早期格式不规范的公报时非常关键,后面第4章会细说。
在写代码前,先把基础环境装好,建议用Python 3.10以上版本,然后用pip一次装齐:
bash复制pip install pdfplumber pandas numpy pypdf
pdfplumber负责解析,pandas负责清洗,pypdf负责读取PDF元信息,各司其职。
2.3 批量提取表格的代码骨架
拿到PDF文件列表后,就可以写一个批量的表格提取函数。我用的是一个比较通用的提取骨架,先按页遍历,再把每页的表格行收集起来,最后做初步过滤。下面这个函数处理晚近年份的规范公报基本够用,遇到特殊版式再单独写分支。
python复制import pdfplumber
def extract_airport_tables(pdf_path, start_page=0, end_page=None):
rows = []
with pdfplumber.open(pdf_path) as pdf:
total = len(pdf.pages)
end_page = end_page or total
for page_num in range(start_page, min(end_page, total)):
page = pdf.pages[page_num]
tables = page.extract_tables()
for table in tables:
for row in table:
cells = [cell.replace('\n', '') if cell else '' for cell in row]
rows.append(cells)
return rows
这里有个细节:不同年份的PDF,机场明细表可能从第3页开始,也可能从第4页开始,所以start_page不能写死。我的做法是先把每一页的内容文本抽出来,用关键词定位“旅客吞吐量”“机场排名”等字样,找到表格真正的起始页,再开始提取表格。这样比拿着页码硬试要稳定得多。
还有一点,pdfplumber的extract_tables返回的是嵌套列表,每个单元格如果是多行文本,默认会带换行符,所以我在上面代码里统一做了replace('\n', '')。千万别省这一步,否则下一步用pandas读进去时,所有字段都会变成带“\n”的脏字符。
3. 数据清洗与标准化
3.1 数值统一与单位换算
PDF表格拿到手之后,最烦人的就是单位不统一。旅客吞吐量大部分年份用的是“万人次”,但有些年份的表格里会直接用“人”为单位的整数;货邮吞吐量更夸张,“吨”“万吨”“公斤”都可能出现。要在pandas里做计算,必须先统一单位,我的规则是旅客统一为万人次,货邮统一为吨。
写一个转换函数来处理是比较稳妥的做法。遇到字符串里带“万”的,就乘以10000;遇到逗号分隔符的就先去掉;遇到空值或“—”就保留NaN,不要强行填0,因为机场可能确实没有公布该指标。
python复制def normalize_passengers(value):
if value in ('', '—', '-', None):
return float('nan')
text = str(value).replace(',', '').strip()
if '万' in text:
return float(text.replace('万', ''))
return float(text) / 10000
def normalize_cargo_to_ton(value):
if value in ('', '—', '-', None):
return float('nan')
text = str(value).replace(',', '').strip()
if '万' in text:
return float(text.replace('万', '')) * 10000
if '公斤' in text:
return float(text.replace('公斤', '')) / 1000
return float(text.replace('吨', ''))
注意小数点精度问题。旅客吞吐量统一成万人次后,原表里可能是140.56,也可能直接是1405600人次,前者转出来是140.56,后者转出来才是140.56,但如果不统一单位,后续做同比计算就会差出万倍。做完转换后,一定要抽查几个已知机场的数据和原始公报核对一遍。
同比增速字段也是个坑。公报里通常写成“14.3%”这样的字符串,需要去掉百分号再转float。有些年份增速还可能是“-5.2%”,负号一定不能丢。还有个隐蔽的问题:当某个机场引入当年没有可比的基数时,增速位置会显示“—”或者空白,这种情况应当保留NaN而不是当作0,否则年均增长率的计算会严重失真。
3.2 机场名录校准
机场名称看起来简单,实际上是整个清洗过程中最需要耐心的一步。同一座机场在不同年份的公报里可能用简称,也可能用全称;比如有的年份写“广州”,有的年份写“广州白云”,还有的年份带上“国际/国内”后缀。
更具挑战性的是机场本身发生变化,例如北京南苑机场在2019年民用航空功能关闭,取而代之的是当年投运的北京大兴机场;青岛流亭机场停用后,青岛胶东机场承接了它的全部运力。这类变化如果不处理,长表里就会出现两个不同的机场名对应同一座城市的客运数据,直接按机场名分组统计就会得出错误的结论。
我的处理策略是建立一个“机场别名映射表”,把不同年份出现的别名统一到当前官方名称上。映射表不能凭空创造,而是要从历年年报中提取出来,每遇到一个不认识的名称,就去查当年的公报说明和机场代码表。这样累计下来,映射表大概有几十条记录,量不大但每条都很重要。
在代码层面,我会维护一个Python字典,然后通过pandas的replace方法批量替换:
python复制airport_alias = {
"北京南苑": "北京南苑(退役)",
"青岛流亭": "青岛胶东",
"广州": "广州白云",
}
df['airport_name'] = df['airport_name'].replace(airport_alias)
这个替换动作一定要在生成最终表之前单独跑一遍,并且替换后重新检查主键的唯一性。如果替换后出现了“同一年、同一机场名、两行数据”,多半说明当年的汇总逻辑出了问题,需要回到原始公报核对。
3.3 异常值识别与补全策略
数据清洗的最后一道关卡是异常值。我比较常用的是“横向对比法”:对同一个机场的多年数据,如果某一年旅客吞吐量突然变成正常年份的十分之一,或者货邮吞吐量波动超过正常逻辑,就回到原始公报人工复核。这个方法比均值标准差检测更实用,因为民航数据在2020至2022年期间本来就会出现比较大的波动,直接套用统计门槛反而会把真实变化误判成异常。
还有一类异常是指标的“跳出”,比如某机场的货邮吞吐量从万吨级变成吨级,通常是单位解析错误;某机场的排名和旅客吞吐量排序明显矛盾,通常是行错位。这些都要靠规则去筛,所以我一般在pandas里写几个简单的校验条件,一次性输出所有可疑行,再去PDF里人工核对。
补全策略要克制:宁可保留NaN,也不要从其他年份或其他机场去“合理推测”。民航生产数据每个机场、每年的统计都是独立的,任何插补都会污染后续分析。如果某个机场在某年确实没有单独公布数据,可以把它归入“其他”类别,或者干脆在分析时只使用有值的数据,这是最诚实的做法。
4. 数据核验与高频问题排查
4.1 用全国总量反向核验
清洗完之后,必须做一次整体核验,方式是用公报正文给出的全国总量数据去反向校验明细表的求和结果。原理很简单:所有机场明细的旅客吞吐量之和,应该等于公报正文中的全国旅客吞吐量总量——精确度虽然因为四舍五入会有细微差异,但误差通常不会超过1%。
我实际的操作是先手动从公报正文中提取全国总量,存成一个summary表,然后把明细表按年份分组求和,再计算两者之间的差值。差值为0或只有微小四舍五入差异的年份,基本可以认为解析成功;差值超过5%的年份,就要回到原始PDF检查是不是漏掉了一些机场的行。
这个方法不需要复杂的机器学习和算法,但对发现系统性问题极其有效。我记得有一年跑完求和总是比公报总量少了一截,排查后发现是PDF表格的某一页在提取时因为“表头跨页”被跳过,那年的几十个机场明细全部丢了,靠总量核验法一下就暴露了出来。
| 年份 | 明细求和(万人次) | 公报总量(万人次) | 差异 |
|---|---|---|---|
| 2019 | 135162.9 | 135162.9 | 0 |
| 2020 | 89942.6 | 90401.7 | -0.51% |
| 2021 | 90712.4 | 90712.4 | 0 |
用这个表再配上各年份的原始公报,基本可以判断哪些年份需要重新解析。
4.2 高频坑与排查方法
PDF解析的坑千奇百怪,但绝大多数可以归为这么几类。
第一类是表格线缺失导致的列错位。有些年份的PDF在转制时表格线变成了灰色半透明,pdfplumber默认的lines策略识别不到,所有字段就会向左挤在一起。处理方法是改用lines+edges混合策略,或者直接切换到text策略,再用坐标排序重排单元格。我在处理2009年前后的公报时反复遇到过这种问题,最后是用坐标排序的方式解决的。
第二类是跨页表头。机场数量多时,一张表会跨好几页,每页顶部都有“机场名称 旅客吞吐量 货邮吞吐量”这样的表头行。如果不做过滤,这些表头会被当成数据行混进结果里。解决办法是设置一个关键词黑名单,凡是第一个单元格是“机场名称”或“排名”的行,一律跳过;如果表头恰好只有“旅客吞吐量”这种词,就需要结合上一行内容判断。
第三类是同一个机场的多个数据被拆在多行。这种情况多出现在PDF单元格内有换行时,比如机场名称后面跟着英文名,pdfplumber可能会把它们拆成两行。我的处理方式是先把单页的所有表格行合并,再按“第一个非空单元格”重新分组,避免把一机场的完整记录拆碎。
下面这张表是我整理的高频问题和对应的排查思路:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 解析后行数明显少于公报机场数 | 跨页表格被漏提取 | 用总量核验法定位年份,重跑该页 |
| 所有字段整体左移一列 | 表格线缺失,列边界错位 | 改用edges策略或坐标分组 |
| 某一机场数据为NaN | 原始公报使用“—”代替 | 保留NaN,不要填0 |
| 同一年份同一机场出现两行 | 跨页时表头被误当数据行 | 增加表头过滤规则 |
| 机场名带有换行符 | PDF单元格多行文本 | 统一replace换行符 |
4.3 终态数据集输出
所有清洗和核验完成后,就可以把数据导出成标准格式了。我个人习惯是同时输出CSV和Parquet两种格式:CSV方便快速预览和Excel查验,Parquet方便后续在Python里做高性能分析。如果还要配合BI工具,可以额外写一份SQLite数据库文件,具体看项目需要。
python复制df.to_csv("airport_stats_2006_2024.csv", index=False, encoding="utf-8-sig")
df.to_parquet("airport_stats_2006_2024.parquet", index=False)
输出时还有一些值得注意的细节。CSV编码建议用utf-8-sig而不是默认的utf-8,否则用Excel打开时中文机场名会变成乱码。年份字段保持int类型,不要让pandas自动转成float,否则2024会被显示成2024.0。最后,在数据文件夹里附一个README文件,把字段说明、单位和已知的缺失年份写清楚,方便日后自己回来查数据时不用重新回忆。
我自己还会额外保存一份“原始解析中间表”,也就是从PDF直接提取的、尚未做单位统一和名称映射的脏数据。这不是冗余,而是为了将来发现某年名称映射错误时,可以直接从中间表重新清洗,而不需要重新去解析PDF。这个习惯帮我省了不止一次二次返工的时间。
5. 一点个人心得
跑完2006到2024年这19年的数据,最大的感受是“清洗的速度取决于对数据的熟悉程度”。同样是解析一份PDF,第一次可能要花两个小时,等摸清楚了公报的排版规律,后续年份的解析基本十分钟就能搞定。所以千万不要一上来就自动化,先手工把不同年份的通路打通,再写脚本批量处理,反而是最快的路径。
另外,机场领域的数据还有一个天然难点:机场数量在增加、名字在变化、统计口径在调整,这意味着不存在一个“一劳永逸”的数据表。数据获取完成并不是终点,后续如果民航局发布修订数据,还得重新拉取重跑清洗流程。设计代码时把“重跑”的成本降到最低,比写出一个一次性的解析脚本更有价值。
最后再分享一个小技巧:每一年的公报PDF保留一份原始版本,不要直接在原始文件上改名字,更不要用PDF编辑器去修改表格内容。这些文件是数据溯源的最后依据,一旦解析出可疑数据,回看原始公报是唯一能还原真相的途径。数据获取这件事,慢就是快,留好后路比追求速度重要得多。
