简介:这是中国金融四十人论坛(CF40)宏观经济医生研究系列的第96期月度报告,聚焦2024年9月中国宏观经济运行状况,面向宏观经济研究者、金融机构从业者及政策制定参考人士。报告以数据检验单形式系统梳理了制造业景气、工业利润、出口消费、固定资产投资、物价水平及金融环境等核心指标,并针对内需不足、财政支出支撑不力、房地产风险与高真实利率等问题提出降息、增加PSL支持基建等政策建议。全文包含PMI分企业规模、工业增加值分行业、社零与汽车销售、房地产投资等关键数据点,并附有Z-Score对比表,便于快速定位异常变量。资源包为1份PDF文件,共763KB,内容精简直观,适合用于月度宏观研判与数据存档。该报告上线以来已有45人学习下载,值得关注中国经济短期走势的读者参考。
1. 当你看一份宏观经济运行检验报告单 PDF 时,先别急着截图
金融从业者每年都会收到大量 PDF 格式的研报,其中“宏观经济运行检验报告单”这类文档尤其特殊:它不是叙事型研报,而是一张张带有多级表头、合并单元格、同比环比双指标的“数据体检单”。大多数人的第一反应是打开、截图、贴进群聊,然后这张 PDF 就再也找不到了。但如果你恰好负责数据报表、指标跟踪或策略复盘,把这类 PDF 手工转录成 Excel 不仅低效,而且容易漏掉小数点、错位数值列。本文想讲清楚的是:如何用 Python 对这类“报告单式 PDF”做文本抽取、表格结构还原、指标清洗和月度自动化追踪,让整份 PDF 变成一份可查询、可回溯、可画图的指标库。适合有基础 Python 经验、正在做数据对接或金融信息化的读者,读完可以直接在自己的环境里跑通一套完整流程。
2. PDF 文本抽取怎么选型:报告单类文档的三大解析路径
2.1 为什么报告单 PDF 比论文 PDF 难解析
报告单类 PDF 的版式通常由排版系统直接生成,每个指标名、数值、单位在页面上的位置是固定的,这看起来比扫描版论文更容易处理。但它的难点在于“视觉表格”和“文本流”不一致:表格线是矢量图形,文本是独立对象,阅读器看到的是规整的格子,解析器看到的却是被分割成几十个 text block 的散落字符串,顺序甚至可能按照生成时的内部逻辑排列,而不是从左到右、从上到下。另一个常见问题是跨页表格:报告单经常把“CPI 当月同比、累计同比、环比”这样的指标竖排在左栏,数值延伸到下一页,单独解析某一页只能得到残缺数据。
2.2 用 PyMuPDF 快速抽取整页文本
先从最简单的文本层抽取入手。PyMuPDF(fitz)是速度最快的 PDF 文本解析库之一,适合先把整份文档的文本按页dump下来,确认内容是否真正存在于文本层。
import fitz # PyMuPDF doc = fitz.open("2024年9月宏观经济运行检验报告单.pdf") for page_index in range(len(doc)): page = doc[page_index] text = page.get_text("text") print(f"--- Page {page_index + 1} ---") print(text) doc.close()这段代码的核心是page.get_text("text"),它返回该页所有文本块按默认顺序拼接后的字符串。参数"text"是输出格式,还可以换成"blocks"、"words"或"dict"。第一次跑完后,重点观察三点:指标名是否完整、数值列是否错位、百分比符号是否与数字分离。如果文本层内容本身就缺漏,那就需要进入 OCR 环节,这里不展开,但报告单一般不会走到那一步。
2.3 pdfplumber 与 PyMuPDF 怎么选:一张表说清边界
很多人会问“直接用 pdfplumber 不行吗”,当然可以。pdfplumber 在表格提取上更接近人的阅读习惯,但速度慢;PyMuPDF 在纯文本和坐标提取上更快,但表格还原需要自己拼。我一般按以下场景选择:
| 场景 | 推荐工具 | 理由 |
|---|---|---|
| 快速确认 PDF 是否有文本层 | PyMuPDF | 秒级返回,API 简单 |
| 抽取页面上每个词的坐标和字体大小 | PyMuPDF | page.get_text("words")自带坐标 |
| 从规整表格线中提取单元格内容 | pdfplumber | extract_table()直接按线切分 |
| 解析无表格线但视觉对齐的报告单 | PyMuPDF + 坐标排序 | 表格线缺失时 pdfplumber 也会失灵 |
对“宏观经济运行检验报告单”这类带完整表格线的文档,我的主流程是:PyMuPDF 做文本探路,pdfplumber 做表格提取,两者相互验证。
2.4 坐标 Rect 的含义与文本块过滤
用 PyMuPDF 抽文本时,最有价值的接口其实是get_text("dict"),它把每个文本 span 的坐标、字体和内容完整地暴露出来。每个 span 都有bbox(边界框),格式是(x0, y0, x1, y1),分别代表左上角 x、左上角 y、右下角 x、右下角 y,单位是 PDF 的点(point),一英寸等于 72 点。这在后续按位置重排文本时会用到:
page = doc[0] data = page.get_text("dict") for block in data["blocks"]: if block["type"] != 0: # 跳过图片块 continue for line in block["lines"]: for span in line["spans"]: x0, y0, x1, y1 = span["bbox"] text = span["text"].strip() if text: print(f"({x0:.1f}, {y0:.1f}) {text}")这段代码把页面上的每个文本片段连同一个可排序的坐标打出来。注意block["type"]为 0 表示文本块,1 表示图片块;一个 block 里可能有多个 line,一个 line 里可能有多个 span,span 才是最小粒度。过滤空字符串和首尾空格是为了避免把排版生成的空白占位符也算进去。这个输出可以作为后续手写坐标重排的调试依据,也能帮你发现表格中“数值右对齐、指标名左对齐”的布局规律。
3. 把报告单里的指标表格还原成可计算的 DataFrame
3.1 用 extract_table 定位指标行
文本抽取只是第一步,报告单的价值在表格里。pdfplumber 的extract_table()能从带表格线的页面中按“线”切分单元格,返回二维数组。对“2024年9月宏观经济运行检验报告单”这类文档,我通常先提取第一页的表格,看表头结构:
import pdfplumber with pdfplumber.open("2024年9月宏观经济运行检验报告单.pdf") as pdf: first_page = pdf.pages[0] table = first_page.extract_table() for row in table[:10]: print(row)extract_table()默认使用页面上的所有垂直线和水平线作为单元格边界,返回list[list],每一行是一个列表,每个元素是单元格里的文本或None。跑完后重点看两点:表格中的表头是单行还是跨行合并、左侧指标列是否出现None(即合并单元格折叠)。如果第一行表头出现["指标", "当月", "同比", None]这样的结构,说明表格有横向合并,需要自己补全层级。
3.2 清洗指标名:去掉空格、全角括号与单位尾巴
从表格里拿到的指标名往往带着各种杂物:中文和数字之间的空格、全角括号、单位(如“(%)”)残留在单元格里。如果不清洗,后面做 DataFrame 的行索引时会凭空多出很多“看似一样其实不同”的列名。
import re def clean_indicator(raw: str) -> str: if raw is None: return "" # 去掉全角和半角空格、换行符 text = re.sub(r"[\s\u3000]", "", raw) # 全角括号转半角 text = text.replace("(", "(").replace(")", ")") # 去掉末尾的单位标记,如 (%), (亿元), (倍) text = re.sub(r"\((?:%|亿元|倍|百分点)\)$", "", text) return text cleaned = [clean_indicator(c) for c in table[1]] print(cleaned)这里的正则\u3000匹配全角空格,\s匹配半角空格和换行。末尾单位删除用了(?:...)非捕获分组,避免把指标名里正常的括号(比如“M1(狭义货币)”)也误删掉。清洗后你会得到干净的指标名,例如“CPI当月同比”“PPI累计同比”,这些可以直接作为 DataFrame 的列名。
3.3 跨页表格拼接与多级表头处理
报告单的表格几乎必然跨页。pdfplumber 对每个页面单独调用extract_table()时,跨页行会被拆成两个表,而且第二页的表格通常会丢失表头列名。常见做法是把所有页的表格行收集起来,手动补上延续部分的指标名:
all_rows = [] current_indicator = None with pdfplumber.open("2024年9月宏观经济运行检验报告单.pdf") as pdf: for page in pdf.pages: tbl = page.extract_table() if tbl is None: continue for row in tbl: if not any(row): continue # 跳过全空行 if row[0] and row[0].strip(): current_indicator = clean_indicator(row[0]) # 若第一列为空,则继承上一行的指标名 row[0] = current_indicator if not row[0] else clean_indicator(row[0]) all_rows.append(row)这个逻辑核心是“前向填充”:当某行的指标列是空白时,说明这是上一指标的延续数值行,用current_indicator变量记住最近的非空指标名并补进去。实际操作中,报告单里同一指标可能包含“当月”“累计”“同比”“环比”四个数值列,跨页后右侧数值列被拆散,补齐指标名后就变成了可合并的长表结构。
3.4 指标合理性校验:给数据上保险
解析完的数据不能直接入库,必须先做一轮合理性校验。报告单里的指标有天然的取值范围约束,比如同比增速常见在 -50% 到 +50% 之间,景气指数(如 PMI)通常在 30 到 60 之间。写一个通用校验函数,把这层业务常识固化成代码:
VALID_RANGES = { "CPI当月同比": (-5, 10), "PPI当月同比": (-10, 20), "制造业PMI": (30, 60), "社融存量同比": (-10, 30), } def validate_metric(indicator: str, value: float) -> bool: if indicator not in VALID_RANGES: return True # 未知指标不做拦截 low, high = VALID_RANGES[indicator] return low <= value <= high # 示例:解析后的 DataFrame for _, row in parsed_df.iterrows(): ok = validate_metric(row["指标"], row["数值"]) if not ok: print(f"[异常] {row['指标']} = {row['数值']} 超出合理范围")把业务校验和解析逻辑分开写是个好习惯,这样将来指标范围变了,只需要改VALID_RANGES,不用动抽取代码。这里建议先做“预警”而非“删除”,因为特殊月份的真实数据可能确实超出常规区间,直接剔除会掩盖问题。
4. 报告单解析的三个典型坑和对应的排错手段
4.1 文本乱序:按坐标重排而不是相信阅读顺序
PDF 文本抽取最常见的坑是文本顺序不符合视觉顺序。报告单通常是一个页面同时包含多个区块(左上是指标解释、中间是数据表格、右下是脚注),PyMuPDF 默认返回的文本顺序是按文档内部对象树的顺序来的,有可能脚注先于指标名出现。解决办法是拿到get_text("words")后,自己按(y0, x0)排序:
page = doc[0] words = page.get_text("words") # 每个元素: [x0, y0, x1, y1, word, block_no, line_no, word_no] words.sort(key=lambda w: (round(w[1], 1), w[0])) sorted_text = " ".join(w[4] for w in words) print(sorted_text)排序时先按y0(纵坐标)再按x0(横坐标),能模拟“从上到下、从左到右”的阅读顺序。round(w[1], 1)是对纵坐标做近似取整,防止同一行的文字因为行高差异产生 0.1 级别的坐标抖动而被分到不同行。这个技巧对报告单尤其重要,因为表格行间距很小,原始输出经常出现“CPI”在前一页末尾、“当月同比”在后一页开头的情况。
4.2 合并单元格带来的空值:前向填充要看上下文
合并单元格是报告单表格的标准操作,比如“居民消费价格指数”横向合并了两个子单元格,pdfplumber 会把这个值只放到第一列,其余列为None。上一章我用current_indicator做前向填充,但这里有个更隐蔽的坑:有些合并是“纵向跨行合并”,即指标名只出现在第一行,后面的行都是数值行,且数值行本身也可能有合并单元格。这种情况下无脑前向填充会把“指标名”填到“表头行”上,造成错位。
稳妥做法是先从视觉上确认合并方向。把表格打印成文本时,如果row[0]全部为空的行恰好夹在两个非空行之间,那不是跨页,而是上一行指标的垂直合并。判断逻辑可以简化为:只有当当前行第一列为空、且后续列包含至少一个非空数值时,才前向填充;如果整行都为空,直接跳过。
4.3 千分位、百分号与负号的全角半角归一
PDF 表格中数字的格式往往不统一,同一个“1,234.5”在表格里可能是“1,234.5”,也可能是全角逗号“1,234.5”;负号可能是标准的-,也可能是−(U+2212);百分号前面可能残留空格。以下处理函数能把这些情况统一成浮点数:
def parse_number(raw) -> float | None: if raw is None: return None text = raw.replace(",", "").replace(",", "") text = text.replace("−", "-").replace("-", "-") text = re.sub(r"[%\s\u3000]", "", text) try: return float(text) except ValueError: return None其中replace(",", "")去掉千分位,replace("−", "-")把数学负号转成标准负号,最后的正则去掉百分号、空格和全角空格。这一步务必在入库前完成,否则后面做时间序列计算时,字符串和浮点数混在一起会让 pandas 直接崩溃。
4.4 用断言把解析错误挡在上游
解析代码改了一版又一版,最怕的是某一期报表格式微调,之前的规则全部失灵。我习惯在解析流程末尾加一段“结构断言”,用硬性条件卡住明显错误:
assert len(parsed_df) > 20, f"解析数据行数异常: {len(parsed_df)}" assert parsed_df["指标"].notna().all(), "存在空指标名,请检查跨页合并逻辑" assert parsed_df["数值"].apply(lambda x: isinstance(x, float)).all(), "数值列存在非浮点数"三段断言分别检查数据量、指标名完整性和数值类型。格式变了就会立刻报错暴露问题所在,而不是让你拿到一张缺行的表还在手动核对。断言注定只在开发和联调阶段开启,正式跑批时换成日志记录更合适,但它作为第一道防线足够有效。
5. 把这份 PDF 变成可追踪的月度指标库:一个可落地的技巧
5.1 用文件名日期当版本号,建立指标快照
报告单文件名自带日期,这就是天然的版本号。常见做法是把年份和月份解析出来后作为快照标记,每次解析的结果按日期存入同一个指标库,形成时间序列。下面这段代码把 PDF 文件名映射成指标快照,并存入 SQLite:
sqlite3 macro_indicator.db \ "CREATE TABLE IF NOT EXISTS indicator_snapshot ( month TEXT, indicator TEXT, value REAL, PRIMARY KEY (month, indicator) );"import sqlite3, re conn = sqlite3.connect("macro_indicator.db") pattern = r"(\d{4})年(\d{1,2})月" m = re.search(pattern, "2024年9月宏观经济运行检验报告单.pdf") month_tag = f"{m.group(1)}-{int(m.group(2)):02d}" for _, row in parsed_df.iterrows(): conn.execute( "INSERT OR REPLACE INTO indicator_snapshot (month, indicator, value) VALUES (?, ?, ?)", (month_tag, row["指标"], row["数值"]), ) conn.commit() conn.close()INSERT OR REPLACE依赖PRIMARY KEY (month, indicator),同一个月同一指标重复解析时会覆盖旧值,保证幂等。文件名里的年份用re.search提取后转成YYYY-MM格式,排序列名时按字符串排序也正确。
5.2 让走势自己说话:三段代码画出一张检验图
最后一个技巧是用快照数据直接生成当月指标走势预览图。读取库中的历史序列,把本月新增的指标画成折线,一眼就能发现新解析的结果是否偏离历史趋势:
import pandas as pd import matplotlib.pyplot as plt df = pd.read_sql_query( "SELECT month, indicator, value FROM indicator_snapshot WHERE indicator = 'CPI当月同比' ORDER BY month", conn, ) df["month"] = pd.to_datetime(df["month"]) plt.figure(figsize=(10, 4)) plt.plot(df["month"], df["value"], marker="o") plt.title("CPI 当月同比走势") plt.grid(alpha=0.3) plt.tight_layout() plt.savefig("cpi_trend.png", dpi=150)pd.to_datetime能把YYYY-MM字符串直接转成日期类型,pandas 画图时自动处理横轴刻度。如果本月数值突然从 0.7 跳到 3.5,而肉眼可见旁边没有政策事件对应,大概率是解析错位,而不是经济突变。这个验证手段不花时间,却能把“解析正确”从感觉变成可复查的依据。后续每期报告单到货,只需要换文件名重新跑一遍全流程,新的点会自动出现在这张图上。
本文还有配套的精品资源,点击获取