手里有一批跨越十几年的上市公司社会责任报告和ESG报告文本,这件事听起来像是“数据到手、万事大吉”,但真正做过文本分析的人都知道,拿到PDF只是万里长征第一步。2006年到2023年,这十八年间披露规则改了好几轮,报告名称从“社会责任报告”逐步扩展到“ESG报告”“可持续发展报告”,排版从Word转PDF再到网页版,扫描件、图片型PDF、双栏排版、页眉页脚混入正文,各种情况都会遇到。我前后处理过几批类似的长周期文本语料,踩过的坑足够写满一个笔记本。这篇内容就围绕这批文本的获取、清洗、结构化、质量校验和实际研究用途展开,把每个环节里“为什么这么做”和“怎么做才不返工”讲清楚,适合正在做文本分析、ESG研究、信息披露研究,或者单纯想把这批语料用起来的人参考。
1. 先搞清楚这批文本到底是什么形态
很多人一上来就写代码批量读PDF,结果跑完发现一半文件是空的,或者正文里全是乱码。问题出在没有先做形态盘点。2006-2023年的上市公司报告文本,至少存在四种物理形态,处理策略完全不同。
1.1 四种常见文件形态与识别方法
第一种是原生电子版PDF,文字层完整,可以直接抽取,这类在2015年之后占比明显上升。第二种是扫描件PDF,本质是图片,必须走OCR,常见于早期年份或部分传统行业公司。第三种是网页版报告,有些公司只在交易所网站挂HTML页面,需要单独抓取正文。第四种是Word转PDF的混合版,文字层存在但排版混乱,表格和正文交错,抽取后需要大量后处理。
识别方法不复杂:用pdfplumber或PyMuPDF逐页读取,统计每页可抽取字符数。如果某份文件超过70%的页面字符数低于50,基本可以判定为扫描件。我一般会先跑一遍全量统计,生成一张形态分布表:
| 形态类型 | 判定依据 | 占比经验值 | 处理方式 |
|---|---|---|---|
| 原生电子版 | 单页字符数>200 | 约六成 | 直接抽取 |
| 扫描件 | 单页字符数<50且页面有图像 | 约两成 | OCR识别 |
| 网页版 | 文件为HTML或需在线抓取 | 约一成 | 正文提取 |
| 混合排版 | 字符数波动大、表格密集 | 约一成 | 抽取加规则清洗 |
这张表不是让你照搬,而是提醒你:先盘点再动手,比盲目批量处理省下大量返工时间。我见过有人直接对全量文件跑OCR,结果原生电子版也被OCR了一遍,错字率反而升高,因为OCR对清晰文字层的识别不一定优于直接抽取。
1.2 年份跨度带来的披露口径变化
2006年是个关键起点,那年相关披露指引开始推动上市公司发布社会责任报告,早期报告普遍篇幅短、定性描述多、定量数据少。到2015年前后,报告结构逐渐规范,环境、社会、治理三个维度的框架开始成型。2020年之后,ESG这个词在报告标题里出现得越来越频繁,部分公司直接把“社会责任报告”改名为“ESG报告”或“可持续发展报告”。
这个变化对文本分析的影响很直接:同一家公司在不同年份的报告,章节结构可能完全不同。如果你要做跨年对比,不能假设“环境章节”永远在第三章。我的做法是先对每份报告做章节标题抽取,建立一份“年份-章节名”的映射表,再决定分析口径。比如早期报告里“环保”相关的内容可能散落在“社会责任”大章节下,而后期报告会单独设“环境”章节,直接按关键词检索会漏掉早期内容。
提示:跨年分析时,建议先用少量样本(每年抽5到10份)人工核对章节结构,再确定关键词表和抽取规则,不要拿一套规则硬套十八年。
2. 文本抽取环节的实操细节与参数选择
形态盘点完成后,进入抽取环节。这一步的细节决定了后续所有分析的质量上限,值得多花时间。
2.1 PDF抽取工具的选择逻辑
常用工具就那么几个:pdfplumber、PyMuPDF(fitz)、pdfminer。三者的差异主要体现在表格处理和排版还原上。pdfplumber对表格的边界识别比较友好,适合报告里大量存在的定量数据表;PyMuPDF速度快,适合大批量处理;pdfminer对文字层解析细致,但速度偏慢。
我的实际组合是:先用PyMuPDF做全量快速抽取和形态判定,再用pdfplumber对表格密集的页面做二次抽取。这样兼顾了速度和精度。参数上,pdfplumber的extract_text()默认按行合并,遇到双栏排版会把左右栏文字混在一起,需要开启layout=True并配合x_tolerance调整。经验值是x_tolerance=3、y_tolerance=3,对大多数报告排版比较稳。
import pdfplumber with pdfplumber.open("report.pdf") as pdf: for page in pdf.pages: text = page.extract_text(x_tolerance=3, y_tolerance=3, layout=True)layout=True会保留空白对齐,双栏问题能缓解一部分,但不会完全解决。遇到严重双栏的报告,更稳妥的做法是按页面横向坐标切分:先取所有字符的x坐标,找到中间的分隔带,再分别抽取左右两栏。这个逻辑写起来不复杂,但能显著提升双栏报告的抽取质量。
2.2 扫描件OCR的预处理与参数
扫描件走OCR之前,预处理比OCR引擎本身更影响结果。我一般做三步:灰度化、二值化、去噪。灰度化用PIL的convert('L'),二值化用自适应阈值(cv2.adaptiveThreshold),去噪用中值滤波。这三步做完,OCR的错字率能降下来一截。
OCR引擎的选择上,中文报告文本用PaddleOCR的识别效果比较稳,尤其是对表格里的数字和中文混排。参数上,det_db_thresh和det_db_box_thresh默认值对清晰扫描件够用,如果扫描件偏暗或有底纹,适当调低阈值。识别语言设为ch,开启方向分类器use_angle_cls=True,因为有些报告是横版扫描,不开启方向分类会整页识别失败。
from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch", det_db_thresh=0.3) result = ocr.ocr("scanned_page.png", cls=True)这里有个容易忽略的点:OCR结果的行序。PaddleOCR返回的是文本框列表,默认按检测顺序排列,不一定是从上到下、从左到右。需要按文本框的纵坐标排序后再拼接,否则段落顺序会乱。我一般按box[0][1](左上角y坐标)排序,同一行内再按x坐标排序。
2.3 网页版报告的正文提取
网页版报告相对少见,但处理起来最麻烦,因为不同交易所、不同公司网站的页面结构差异极大。通用做法是用BeautifulSoup或lxml解析HTML,定位正文容器。难点在于正文容器的class名千奇百怪,没有统一规律。
我的策略是基于文本密度定位:遍历所有div和article标签,计算每个容器内文本长度与标签数量的比值,比值最高的几个容器大概率是正文区。这个方法不依赖具体class名,泛化性较好。提取后再用正则去掉导航栏、页脚、版权声明等噪声。如果页面是动态渲染的,还需要用无头浏览器抓取渲染后的DOM,这一步会增加处理时间,建议只对确认为网页版的少量文件使用。
3. 清洗与结构化:把一锅粥变成可分析的表
抽取出来的原始文本是一锅粥:页眉页脚、页码、目录、图表标题、脚注全混在一起。清洗的目标是得到干净的正文,并尽可能保留章节结构,方便后续按维度分析。
3.1 页眉页脚与噪声的规则清洗
页眉页脚的典型特征是在多个页面重复出现。做法是统计所有页面的首行和末行,出现频率超过总页数30%的文本行判定为页眉页脚,批量删除。页码用正则匹配,常见形式是纯数字、第X页、- X -、Page X of Y,写几条正则覆盖大部分情况。
目录页的处理要小心:目录里的章节标题和正文里的章节标题文本相同,如果只按文本匹配删除,会把正文标题也删掉。我的做法是按位置判断,目录一般集中在前几页,且目录页的文本行普遍较短、包含大量省略号或点线。对前5页内符合这些特征的行做删除,正文部分不动。
还有一个隐蔽的噪声是图表标题和脚注。图表标题通常以“图X”“表X”开头,脚注通常以数字上标或“注:”开头。这些内容对正文语义分析是干扰,但如果你的研究涉及定量数据,表格内容反而要单独保留。所以清洗规则要分两套:一套用于纯文本语义分析(删表格和图表标题),一套用于数据提取(保留表格,单独结构化)。
3.2 章节结构的还原方法
章节还原是这批文本结构化的核心价值所在。报告一般有明确的章节编号,比如“第一章”“一、”“1.”“1.1”等层级。用正则匹配这些编号模式,结合字号信息(如果能从PDF抽取到字体大小),可以还原出章节树。
实际操作中,纯文本抽取拿不到字号,只能靠编号模式。我用的正则大致覆盖这几种:^第[一二三四五六七八九十]+章、^[一二三四五六七八九十]+、、^\d+\.\d*\s、^\d+\.\d+\.\d+。匹配到编号行后,结合上下文判断层级:章级编号单独成行且前后有空行,节级编号后面紧跟标题文字。
还原出的章节树存成JSON,结构大致是:
{ "report_id": "某公司_2021", "chapters": [ {"level": 1, "title": "环境责任", "content": "..."}, {"level": 2, "title": "碳排放管理", "content": "..."} ] }有了这个结构,后续按“环境”“社会”“治理”维度做聚合分析就方便多了,不用每次都在全文里大海捞针。
3.3 表格数据的单独抽取
报告里的定量数据表是ESG研究的重要素材,比如碳排放量、员工构成、研发投入等。表格抽取用pdfplumber的extract_tables(),但要注意合并单元格的处理。很多报告的表头有跨列合并,抽取出来会变成None或空字符串,需要按列位置回填。
我的经验是:表格抽取后必须人工抽检。自动抽取的表格,行列对齐错误率不低,尤其是跨页表格。跨页表格的识别方法是看表格首行是否在页面顶部重复出现,如果是,说明是续表,需要把两页的表格拼接。拼接时注意去掉重复的表头行。
| 常见表格问题 | 表现 | 处理方式 |
|---|---|---|
| 合并单元格 | 表头出现None | 按列位置回填 |
| 跨页续表 | 表头重复 | 去重后拼接 |
| 数字带千分位 | “1,234”被拆成两列 | 正则合并 |
| 单位混入数值 | “12.5万吨” | 拆分数值与单位 |
4. 质量校验:怎么判断这批文本能不能用
清洗完不等于能用。我见过太多人跳过校验直接跑分析,最后结论站不住脚。质量校验至少要做三层:完整性、一致性、可用性。
4.1 完整性校验:年份覆盖与公司覆盖
先看年份覆盖。2006-2023共十八年,但并非每家公司每年都披露。统计每年有报告的公司数量,画出趋势线,如果某年数量断崖式下跌,要么是数据缺失,要么是那年的披露规则有变化。再看公司覆盖,统计每家公司有多少年的报告,如果大量公司只有零星几年,做面板分析时样本会损失严重。
完整性校验还要看单份报告的完整性:页数是否异常少(比如只有两三页,可能是摘要版而非完整版)、正文字符数是否异常低(可能是抽取失败)。我一般设两个阈值:页数低于10页、正文字符数低于5000的报告,标记为“待人工复核”。
4.2 一致性校验:同一公司跨年文本的连贯性
一致性校验的目的是发现抽取错误。方法是同一公司相邻年份的报告做文本相似度对比。正常情况下,相邻年份报告的结构和措辞有较高相似度(很多公司会沿用上一年的模板)。如果某年相似度骤降,可能是抽取出了问题,也可能是公司换了报告模板,需要人工确认。
相似度用简单的余弦相似度或Jaccard相似度即可,不需要太复杂。我一般对每家公司计算相邻年份的相似度序列,把相似度低于0.3的年份对挑出来人工看。这个步骤帮我发现过好几份“抽取成功但内容错位”的文件,比如章节顺序乱了、表格内容混入正文。
4.3 可用性校验:关键词命中率抽检
最后是可用性校验。针对你的研究主题,选一组核心关键词(比如“碳排放”“员工培训”“董事会独立性”),统计这些词在各年报告中的命中率。如果某个关键词在早期年份命中率极低,可能是早期报告确实没提,也可能是早期报告的表述不同(比如用“环保”而非“碳排放”)。这个校验能帮你调整关键词表,避免分析时漏掉同义表述。
注意:关键词命中率抽检一定要分年份、分行业看。不同行业的报告用词差异很大,制造业报告里“排放”出现频率高,金融业报告里“排放”可能几乎不出现,这不代表数据有问题,而是行业特性。
5. 这批文本能支撑哪些实际分析
文本清洗好之后,价值才真正体现出来。2006-2023年的长周期文本,能支撑的分析类型比很多人想象的多。
5.1 披露质量的时间趋势分析
最直接的分析是披露质量的时间趋势。可以用几个代理指标:报告篇幅(字符数)、定量数据占比(数字字符比例)、章节完整度(是否覆盖环境、社会、治理三个维度)、第三方鉴证情况(报告中是否提及审验)。把这些指标按年份聚合,能看出披露质量的整体走势。
我做过一个粗略统计,2010年之前的报告平均篇幅明显短于2015年之后,定量数据占比也低。这个趋势本身不新鲜,但如果你能细分到行业,会发现不同行业的改善节奏差异很大。比如高排放行业的报告在环境维度的定量数据增长更快,而金融行业在社会维度的描述更细。这类细分结论比“整体在改善”有信息量得多。
5.2 文本相似度与“模板化披露”识别
长周期文本还有一个有意思的用法:识别模板化披露。有些公司连续多年的报告,大段文字几乎一字不差,只改几个数字。用文本相似度对比相邻年份,相似度极高的段落标记为“模板段落”。统计模板段落的占比,可以衡量披露的实质性。
具体做法是把报告按段落切分,对相邻年份的段落做两两相似度匹配,相似度超过0.9的段落对标记为模板段落。这个分析要注意排除固定表述,比如公司简介、免责声明这类本来就该重复的内容。我的做法是先建一个“固定表述黑名单”,匹配前先剔除。
5.3 主题建模与议题演变追踪
如果研究兴趣在议题演变上,可以用主题建模(LDA或BERTopic)对全量文本做主题聚类,再看各主题在不同年份的占比变化。比如“气候变化”相关主题的占比是否逐年上升,“员工权益”主题的表述重点是否从“福利保障”转向“职业发展”。
主题建模的参数上,LDA的主题数建议先按年份分层跑,再合并看趋势,因为不同年份的文本长度和用词差异大,直接全量跑容易把年份差异误判为主题差异。BERTopic对短文本和语义相似度更友好,但计算资源消耗大,建议先抽样跑通再全量。
| 分析类型 | 适用方法 | 注意事项 |
|---|---|---|
| 披露质量趋势 | 指标聚合 | 指标要可复现 |
| 模板化识别 | 段落相似度 | 剔除固定表述 |
| 议题演变 | 主题建模 | 分年份分层跑 |
5.4 与财务数据的交叉分析
这批文本如果和财务数据结合,能做的分析更多。比如披露质量高的公司,其融资成本是否更低;环境维度定量数据披露充分的公司,其环境绩效是否真的更好。这类交叉分析的关键是匹配口径,文本侧的公司标识和财务侧的公司标识要能对上,年份要对齐。建议在清洗阶段就给每份报告打上统一的公司代码和年份标签,避免后期匹配时手工对表。
6. 几个我踩过、你可能也会踩的坑
最后分享几个实际操作中遇到的问题,都是那种文档里不会写、但实际做起来一定会碰到的。
第一个坑是编码问题。早期报告有些是GBK编码的文本,直接按UTF-8读会乱码。批量处理时先做编码探测(chardet库),再按探测结果读取。这个坑不大,但如果不处理,乱码文本会污染后续所有分析。
第二个坑是PDF加密。少数报告设了权限密码,能打开但不能复制文字。这类文件用PyMuPDF读取时会抛异常,需要单独标记出来,要么找无密码版本,要么走OCR。批量处理时一定要加异常捕获,不然一个文件报错会中断整个流程。
第三个坑是表格里的数字被拆行。有些报告的表格排版,一个数字被拆成两行,抽取出来变成“12.”和“5”,合并后变成“12.5”还是“125”完全靠猜。这类问题没有通用解法,只能靠人工抽检发现,然后在清洗规则里针对性地加合并逻辑。
第四个坑是章节标题的层级误判。有些报告用“1.”表示章级,有些用“1.”表示节级,同一份报告里层级编号规则不统一的情况也存在。我的做法是不追求完全自动化的层级还原,而是先自动还原,再对每份报告抽检前三个章节标题,确认层级规则后再批量应用。
第五个坑是年份标签的错误。报告文件名里的年份有时是发布年份,有时是报告覆盖的年份,两者可能差一年。比如2021年发布的报告,覆盖的是2020年的数据。做时间序列分析时,这个差异会导致趋势错位。建议在元数据里同时记录“发布年份”和“报告年度”,分析时明确用哪个。
提示:所有清洗规则和校验脚本,建议版本化管理。这批文本处理周期长,规则会反复调整,没有版本管理的话,过两个月你自己都说不清哪版规则跑出来的结果是对的。
这批2006-2023年的报告文本,处理起来确实费工夫,但一旦清洗到位、结构还原清楚,它能支撑的分析深度是二手整理数据比不了的。我在实际项目里的体会是,前期在形态盘点和质量校验上多花的时间,后期会以“少返工、结论稳”的形式加倍还回来。如果你正准备动手,建议先拿20份不同年份、不同行业的样本跑通全流程,确认规则稳定后再上全量,这个顺序能帮你避开大部分坑。