简介:普华永道2020年9月发布的《全球并购行业洞察:工业制造与汽车行业》是一份面向企业战略、并购投资与行业研究人员的精品报告。报告以2020年上半年疫情冲击下的全球并购数据为基础,详细比较航空航天与国防、汽车、工程与建筑等子行业的交易量变化,并针对供应链中断、估值不确定、债务融资困难等挑战,提出修复价值损失、重新思考保值战略、设定业务释放方向等具体建议。资源共14页,为1个PDF文件,压缩包大小仅1.47MB,阅读轻量、便于随时查阅;目前已有155人学习。报告中配有交易金额与数量走势图、分行业概览和普华永道全球交易主管合伙人观点,适合需要快速把握后疫情时代工业制造与汽车行业并购机遇、辅助战略决策的读者。
1. 一份 14 页的并购 PDF,为什么值得写代码去拆
一份普华永道在 2020 年 9 月发布的《全球并购行业洞察:工业制造与汽车行业》,只有 14 页,看起来就是标准的精品报告 PDF。可如果你需要把报告里的并购数据放进自己的分析体系,麻烦就来了:PDF 里的文字块、图表、表格是给眼睛看的,不是给程序用的。做过行业数据分析的工程师都有体会,手抄报表不仅慢,而且容易在美元金额、百分比、同比增幅这些精度敏感字段上出错。这篇博文想讲清楚的就是这样一条链路:用 Python 解析这类行业洞察 PDF,把文本、表格和可计算的数字提取出来,再按工业制造和汽车行业两个维度做成结构化数据。适合需要做行业跟踪、竞争情报或者给决策层做整合分析的 IT 和咨询从业者。
2. 用 pdfplumber 提取普华永道全球并购行业洞察的文本与版面
拿到这份 PDF 文件,第一步不是急着抽数据,而是先摸清它的构造。行业报告大多由排版软件导出,文字和表格都有独立的文本对象,但也有少数是扫描后合订的影印版。两种类型的处理路径完全不同:文本型直接抽取,扫描型要先做 OCR。所以解析流程的第一道分叉就在这里。
2.1 先判断 PDF 是文本型还是扫描型
判断方法很简单:用 PyMuPDF 打开文件,读取每页的文本字符数。如果某一页文本字符数接近 0,而页面尺寸和图片尺寸都正常,基本可以判定这页是图片。对于 14 页的报告,通常可以按页统计,看看哪些页需要走 OCR。
2.1.1 用 PyMuPDF 做快速检测
下面这段代码可以快速输出每页文本量和图像数量:
import fitz pdf_path = "PwC_Global_M&A_Industrial_Automotive_2020.9.pdf" doc = fitz.open(pdf_path) for page_num in range(len(doc)): page = doc.load_page(page_num) text = page.get_text("text") images = page.get_images(full=True) print(f"page {page_num + 1}: chars={len(text.strip())} images={len(images)}") doc.close()这段代码用 PyMuPDF 的get_text提取页面文本,len(text.strip())得到有效字符数。如果某页字符数只有几十甚至为 0,同时图片数量不为 0,说明这一页大概率是扫描图片。普华永道这类咨询公司的 PDF 通常是矢量文本和图片混排,但券商二次分发时可能压成图片,所以这一步值得保留。
提示:
get_text("text")会带出文字的换行和空白,字数统计用strip()后长度比较靠谱;用"words"模式则是单词级,后续处理排版信息时会更精确。
2.2 按页解析文本块,清洗页眉页脚
确认是文本型 PDF 之后,就可以用 pdfplumber 做细粒度解析。pdfplumber 与 PyMuPDF 不同,它不只返回字符串,还能告诉你每个字符、单词、矩形和线条的坐标。对行业报告这类版式多变的文档,坐标信息几乎是必须的,因为页眉、页码、页脚里的"普华永道""2020 年 9 月"这些字样都在固定位置,不按坐标过滤会把它们混进正文。
2.2.1 提取文本块的过滤参数
我用 pdfplumber 抽取按页划分的文本块时,会先做一个基础坐标过滤:
import pdfplumber def extract_body_words(pdf_path, page_num, x_limit=45, top_limit=50, bottom_limit=30): body_words = [] with pdfplumber.open(pdf_path) as pdf: page = pdf.pages[page_num] page_height = page.height for word in page.extract_words( extra_attrs=["fontname", "size"], use_text_flow=False, ): x0, top, x1, bottom = word["x0"], word["top"], word["x1"], word["bottom"] if x0 < x_limit or x1 > page.width - x_limit: continue if top < top_limit or bottom > page_height - bottom_limit: continue body_words.append(word) return body_wordsextract_words是 pdfplumber 的核心方法,返回每个单词的边界框和字体信息。这里的x_limit、top_limit、bottom_limit分别表示左右页边距和上下页眉底边距,单位是 PDF 的默认磅值。报告版式比较极端的时候,我会把这几个参数调大或调小;如果发现正文文字被误删,优先检查page.width是否正确,因为 PDF 可以设置页面旋转角度。
注意:use_text_flow=False时按物理位置分组单词,适合多栏版式;如果报告是双栏,可以把这参数改成True并按栏重组,否则阅读顺序会乱掉。
2.3 锁定并购关键词
文本块拿到手,下一步就是找报告里真正和"并购洞察"相关的章节。常见的做法是统计关键词在每一页的出现频次。例如"M&A""merger""acquisition""并购""交易金额""交易数量"这些词,在普华永道这类报告里几乎每几段就会出现。我习惯用正则做一次粗筛:
import re keyword_pattern = re.compile( r"M&A|merger|acquisition|并购|交易额|交易量|deal value|deal count", re.IGNORECASE, ) page_hits = {} with pdfplumber.open(pdf_path) as pdf: for i, page in enumerate(pdf.pages): page_text = page.extract_text() or "" hits = keyword_pattern.findall(page_text) page_hits[i + 1] = len(hits) hot_pages = sorted(page_hits.items(), key=lambda x: x[1], reverse=True) print(hot_pages[:8])这段代码把每页关键词命中次数排序,排在前面的页通常就是核心数据页。对于 14 页的精品报告,重点内容往往集中在中间页,例如第 4 到 10 页。这一步的意义不是省去人工阅读,而是让后续表格抽取可以跳过无效页,少跑无谓的解析任务。
后面我们抽取表格时,就可以先用这个页频次把参数范围收紧,避免 14 页全量抽取浪费时间。
3. 从 2020.9 报告中抽取并购表格数据
文本可以过滤出结论,但并购行业洞察里最有价值的往往是表格:各区域交易金额、年度同比、工业制造与汽车行业的交易数量分布。这些表格排版复杂,直接extract_text会得到换行错乱的字符串。必须用专门的表格抽取工具,并且针对跨页表格做合并处理。
3.1 为什么要单独处理表格:难点与选型
PDF 表格和 HTML 表格完全不同。HTML 有明确的行列标签,PDF 只有坐标与线条,甚至表格线是断的。普华永道这类咨询公司喜欢用细线表、底纹表和混排表,有的表格在左右页跨页,有的在表头重复。pdfplumber 内置了表格提取,但对于无完整线条的"无线表",效果会差一些。我一般会同时准备 pdfplumber 和 camelot,前者处理有线的格子,后者处理需要对齐推测的表格。
3.1.1 pdfplumber 的 table 参数与关键配置
pdfplumber 的page.extract_table依赖table_settings来识别表格线。常用的参数如下:
| 参数 | 默认值 | 作用与推荐设置 |
|---|---|---|
vertical_strategy | lines | 设为lines时只看显式线条,text时根据文字对齐推断竖线 |
horizontal_strategy | lines | 同上,用于横线识别,报告中的底纹表常需要text |
snap_tolerance | 3 | 两条线之间的吸附距离,超过该距离视为分离线;线断较多时调到 5~8 |
join_tolerance | 3 | 十字交叉点的判定容差,线段错位时调大,否则会漏行 |
edge_min_length | 3 | 只保留大于该长度的线条,过滤装饰性短线,一般设 10 |
一个示例配置:
settings = { "vertical_strategy": "lines", "horizontal_strategy": "text", "snap_tolerance": 5, "join_tolerance": 5, "edge_min_length": 10, } table = page.extract_table(settings)把horizontal_strategy设为text是个小技巧:很多咨询报告的行线是浅灰色底纹而不是黑色线段,lines会识别不到;用文字纵向对齐来推断行边界,更适合这种"看起来有线、其实是几何图形"的表格。但这个参数也有副作用,遇到多行单元格时会把一行拆成多行,需要在清洗阶段做合并。
3.2 用 camelot 处理复杂跨页表格
对于跨页表格,我一般会先用 pdfplumber 定位表格边界,再用 camelot 按页面连续抽取。camelot 有lattice和stream两种模式,前者识别显式线条,后者基于文本流。这份报告如果表格线完整,可以这样:
import camelot tables = camelot.read_pdf( "PwC_Global_M&A_Industrial_Automotive_2020.9.pdf", pages="4-8", flavor="lattice", line_scale=40, )line_scale是一个容易被忽略的参数。它控制视觉线段的长度缩放,值太小时虚线会被拆成大量小段,值太大又会把装饰线误认为表格线。遇到表格线断断续续时,我会从 40 往上下试,每次调 10,观察table.parsing_report里的accuracy和whitespace两个指标。accuracy接近 100、whitespace接近 0,通常表示识别结果稳定。
camelot 抽取出的tables对象有.df属性,能直接得到 DataFrame。对于跨页表格,把两个页面的.df用pandas.concat拼接即可,前提是列名一致。普华永道这类报告的表头在每页重复,所以拼接时要忽略重复行:
import pandas as pd frames = [] for page_num in range(4, 9): t = camelot.read_pdf(pdf_path, pages=str(page_num), flavor="lattice")[0].df if frames: if t.iloc[0].tolist() == frames[0].iloc[0].tolist(): t = t.iloc[1:] frames.append(t) merged = pd.concat(frames, ignore_index=True)逻辑说明:这里逐页抽取并去掉重复表头;用第一行内容判断是否为表头。识别跨页表头是否真的重复,原报告有可能只在首页显示表头,这时t.iloc[0].tolist()与第一页表头不一致,上面的判断会误删数据。更稳妥的做法是同时检查列名相似度,或直接观察页首和页尾是否有明显的完整行。
3.3 表格清洗:把金额单位、时间字段统一成可分析格式
表格抽出来后,里面通常是"US$ billion"、"5,200"、"12.3%"之类的字符串。这类数据要进 pandas 做计算,必须先清洗。我写了一个小流程:
def clean_number(value): if value in (None, "", "-"): return float("nan") value = value.replace(",", "").replace("US$", "").replace("%", "").strip() try: return float(value) except ValueError: return float("nan") merged["deal_value"] = merged["交易额"].apply(clean_number) merged["growth_rate"] = merged["同比增幅"].apply(clean_number)注意replace的调用是有顺序的:先去掉千分位逗号,再去掉币种符号,最后统一转 float。如果原始数据混有中文全角逗号和括号里的负数,比如"(5,200)",上面这段会直接返回 NaN,需要先判断value.startswith("(")并处理负号,否则后续聚合结果会缺一大块。
表格清洗是整条流水线里最耗时的部分。14 页的报告,大约一半页面有表格,手工核对一遍大概需要 20 分钟,但程序化清洗逻辑写好后,后续再收到 9 月或 10 月的同类报告,可以直接复用这份清洗规则。
4. 面向工业制造与汽车行业的并购洞察分析
表格数据就位后,真正的行业洞察分析才开始。报告的核心叙事是把工业制造和汽车行业放在一起对比。这两个行业在并购逻辑上有明显差异:工业制造偏重产能整合和自动化升级,汽车行业则围绕电动化、智能驾驶和供应链重构展开。解析报告时,我们不仅要提取数字,还要把文本里的行业标签映射到统一的分类体系里,才能让"普华永道全球并购行业洞察"里的内容变成自己的分析素材。
4.1 行业分类与关键词映射
由于 PDF 文本里同一行业可能使用不同称呼,比如"工业制造""industrial manufacturing""离散制造""汽车整车""automotive"等,需要建一个关键词映射表。我一般用字典来配置:
industry_keywords = { "工业制造": ["industrial", "manufacturing", "离散制造", "机械", "工业设备"], "汽车行业": ["automotive", "auto", "整车", "零部件", "汽车", "electric vehicle"], }这段代码的用途不在抽取,而在对抽取出的段落和表格做标注。我们会遍历每一页的文本,按关键词命中哪个分类,就把该段标记为对应行业。注意"汽车"和"自动化"这类词很容易误命中:"自动化"不等于"汽车行业",所以映射表里优先放长尾词,比如"electric vehicle"比"vehicle"更精准。
4.1.1 工业制造和汽车行业的典型并购标签
| 标签层级 | 工业制造 | 汽车行业 |
|---|---|---|
| 交易标的 | 工业机器人、数控机床、电机泵阀 | 整车平台、动力电池、智能驾驶 |
| 交易目的 | 产能扩张、垂直整合、自动化替代 | 转型新能源、供应链安全、软件定义汽车 |
| 常见表述 | capex、capacity utilization、smart factory | EV transition、battery supply chain、software-defined vehicle |
这张表可以作为关键词枚举的依据。实际分析时,我不建议只用一个词表,而是按章节统计词频,观察哪些词出现在标题和结论句中。还是回到 pdfplumber:对文本块按字体大小排序,fontname中含 "Bold" 或字号大于正文的,大概率是章节标题,把这些标题归拢起来,就能自动拼出报告大纲。
4.2 用 pandas 聚合交易趋势
如果报告中有按季度或按年度的交易明细表,可以把它转成透视表,计算每个行业的交易总金额、交易数量和平均单笔金额。
df = pd.DataFrame({ "industry": ["工业制造", "工业制造", "汽车行业", "汽车行业"], "quarter": ["2020Q1", "2020Q2", "2020Q1", "2020Q2"], "deal_value": [152.3, 164.8, 89.5, 107.2], "deal_count": [112, 118, 76, 91], }) pivot = df.groupby("industry").agg( total_value=("deal_value", "sum"), total_deals=("deal_count", "sum"), ) pivot["avg_value"] = pivot["total_value"] / pivot["total_deals"]这里使用了 pandas 的groupby和agg组合。agg里的两行分别对交易额求和、对交易数求和,再手动计算平均单笔金额。要注意mean在这里不是平均值字段,而是对多个季度做均值,如果报告里只有一份 2020.9 单期数据,就不应该把季度维度当作唯一维度;可以改成按区域维度聚合,比如北美、欧洲、亚太。
提示:报告只有单期时,"趋势"更多是横截面对比。将这种对比写成
total_value和total_deals的排序,反而比虚构一个时间序列更可靠。
4.3 生成可读的洞察摘要,而不是堆数据
聚合完之后,最终交付物最好是一段能让人读懂的摘要。例如"工业制造领域并购额在前两季度合计 317.1 亿美元,交易数量 230 宗;汽车行业合计 196.7 亿美元,交易数量 167 宗"。把这种摘要自动生成,可以避免每次人工从 DataFrame 里抄数字。
我习惯写一个简单模板:
def summarize_industry(df, industry): sub = df[df["industry"] == industry] total_value = sub["deal_value"].sum() total_deals = sub["deal_count"].sum() return f"{industry}:累计并购金额 {total_value:.1f} 亿美元,交易数量 {total_deals} 宗,平均单笔约 {total_value / total_deals:.1f} 亿美元"注意,这里用的是示例数据展示逻辑,实际运行时数据来自前面解析出的表格。写摘要时还要保留单位的可读性,最好把原始清洗后的数值统一成亿美元。如果抽样核对后发现原始报告用的是百万美元,那这个total_value需要除以 10 再输出,否则数字会差两个数量级。
5. 把这套流程沉淀成可直接运行的命令行工具
前面的代码分散在多个环节,真正要处理多期报告时,最好把它们串成一个命令行工具。这样 14 页的精品报告也好,后续同系列报告也好,只需要传入文件路径和页码范围,就能自动产出结构化数据。
5.1 最小实现:解析-提取-汇总三步命令
一个实用的命令行工具可以这样组织:
python mna_report_parser.py \ --input PwC_Global_M&A_Industrial_Automotive_2020.9.pdf \ --pages 3-12 \ --output summary.csv对应的 argparse 参数:
import argparse parser = argparse.ArgumentParser(description="解析并购行业洞察 PDF") parser.add_argument("--input", required=True, help="输入 PDF 路径") parser.add_argument("--pages", default="1-14", help="要处理的页码范围,例如 3-12") parser.add_argument("--output", default="summary.csv", help="输出 CSV 路径") args = parser.parse_args()--pages的作用是跳过封面和目录。普华永道这类报告前两页通常是封面和免责声明,直接解析会得到无意义文本。页码范围可以用函数解析成列表:
def parse_page_range(spec): start, end = spec.split("-") return list(range(int(start) - 1, int(end)))页码从 1 开始,Python 列表从 0 开始,所以这里要减 1。这个细节很容易踩坑:直接用range(int(start), int(end))会把第一页丢给pages[1],也就是第二页,封面反而被跳过。
5.2 验证 14 页 PDF 的解析质量
自动化工具跑完不代表结果正确。我一般做三样验证:第一,用 pdfplumber 检查总页数是否为 14,防止文件被篡改或版本不对;第二,对照原文随机抽取 3 个表格行,检查金额和交易数量是否与 PDF 页面一致;第三,检查缺失率,比如deal_value的 NaN 数量不能超过 10%。
assert len(pdf.pages) == 14, f"expect 14 pages, got {len(pdf.pages)}" na_ratio = merged["deal_value"].isna().mean() if na_ratio > 0.1: raise ValueError(f"missing ratio too high: {na_ratio:.2%}")isna().mean()是计算缺失率的一个小技巧:布尔值取均值就是缺失占比。如果超过 10%,说明表格识别或清洗逻辑出了问题,应该回去调snap_tolerance或line_scale,而不是直接使用结果。
5.3 一个高频踩坑:PDF 的表格线断线导致行错位
最后分享一个我反复遇到的坑:行业报告里的表格线经常在列与列之间断开,尤其是"普华永道"这类有底色的表格。pdfplumber 和 camelot 都会把断线识别成两条独立线段,导致同一行被拆成多行。常见处理方法是调大snap_tolerance和join_tolerance,但调太大又会把相邻行合并。更稳的办法是先检查settings里的edge_min_length,把太短的装饰线过滤掉。如果断线出现在一列数据的中部,另一个思路是直接用text策略替代lines,让文字对齐来补足断线。我通常会在解析后打印extract_table的行数,和肉眼看到的行数做一次比对,差 10% 以上就先调edge_min_length,而不是马上就上 OCR。
本文还有配套的精品资源,点击获取