ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Python解析并购行业报告PDF:从文本到表格的自动化实践

用Python解析并购行业报告PDF:从文本到表格的自动化实践 简介普华永道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_MA_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(fullTrue) print(fpage {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_limit45, top_limit50, bottom_limit30): 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_flowFalse, ): 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_flowFalse时按物理位置分组单词适合多栏版式如果报告是双栏可以把这参数改成True并按栏重组否则阅读顺序会乱掉。2.3 锁定并购关键词文本块拿到手下一步就是找报告里真正和并购洞察相关的章节。常见的做法是统计关键词在每一页的出现频次。例如MAmergeracquisition并购交易金额交易数量这些词在普华永道这类报告里几乎每几段就会出现。我习惯用正则做一次粗筛import re keyword_pattern re.compile( rMA|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(), keylambda x: x[1], reverseTrue) 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_strategylines设为lines时只看显式线条text时根据文字对齐推断竖线horizontal_strategylines同上用于横线识别报告中的底纹表常需要textsnap_tolerance3两条线之间的吸附距离超过该距离视为分离线线断较多时调到 58join_tolerance3十字交叉点的判定容差线段错位时调大否则会漏行edge_min_length3只保留大于该长度的线条过滤装饰性短线一般设 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_MA_Industrial_Automotive_2020.9.pdf, pages4-8, flavorlattice, line_scale40, )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, pagesstr(page_num), flavorlattice)[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_indexTrue)逻辑说明这里逐页抽取并去掉重复表头用第一行内容判断是否为表头。识别跨页表头是否真的重复原报告有可能只在首页显示表头这时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 factoryEV 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_MA_Industrial_Automotive_2020.9.pdf \ --pages 3-12 \ --output summary.csv对应的 argparse 参数import argparse parser argparse.ArgumentParser(description解析并购行业洞察 PDF) parser.add_argument(--input, requiredTrue, help输入 PDF 路径) parser.add_argument(--pages, default1-14, help要处理的页码范围例如 3-12) parser.add_argument(--output, defaultsummary.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, fexpect 14 pages, got {len(pdf.pages)} na_ratio merged[deal_value].isna().mean() if na_ratio 0.1: raise ValueError(fmissing 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。本文还有配套的精品资源点击获取
返回列表