做上市公司社会责任报告文本研究的同学,大概率都有过这样的经历:从各类数据库和网站上一份份手动下载PDF报告,再转成Word、再想方设法转成txt,中间各种排版错位、乱码、表格丢失,折腾两三天才能凑齐一个像样的样本。最近我为了更新自己的研究语料,把2006年到2024年上市公司社会责任报告的txt格式文本数据做了一次比较彻底的整理和复验,前前后后花了一周多时间。这篇内容就把完整的处理过程、踩坑记录和应用思路都放出来,给正在找数据或已经拿到数据但不知道怎么下手的读者做个参考。
先说清楚一个前提:这份语料的样本单位是一家上市公司在某一年发布的“社会责任报告”全文,格式是纯txt。它不是一个带字段的数据库,也不是已经清洗好的结构化表格,而是一堆能直接打开读的文本文件。这反而是我觉得最舒服的地方——文本分析的工作本来就不该从“别人整理好的Excel”开始,而应该从最原始的文本出发,自己掌控每一步清洗和特征提取的逻辑。
1. 这批数据到底覆盖了什么,以及它能支撑哪些研究
1.1 时间跨度从2006年开始意味着什么
严格来说,A股上市公司社会责任报告的公开披露不是从2006年才出现的,但2006年是一个比较关键的分水岭。在那之前,主动发布社会责任报告的上市公司数量很少,样本零散,内容也多偏向企业宣传册式的专刊,或者年报里顺带的一个小节,很难形成连续可比的文本语料。2006年以后,披露数量稳步上升,到2010年以后,很多行业的头部公司开始把社会责任报告作为一份独立文件来编制发布,篇幅和结构也随之规范化。
这个时间起点对研究设计非常关键。如果你的研究需要观察“披露行为变化”的时间序列,2006-2024是一段相当完整的周期,能覆盖企业从被动披露转向主动披露、从单纯展示公益到体系化披露ESG信息的变化过程。我统计过自己这份语料里的年份分布,2012年以前的报告数量少,单篇文本的重心集中在公司治理和公益捐赠;2015年之后,环境类词汇和具体量化指标明显增多。研究结论不一定放之四海而皆准,但至少能真实反映这段时间里的文本趋势。
如果只需要“最近五年”的截面数据做横截面分析,2006年的起点意义不大;但如果你要做面板数据,需要对公司逐年评分,那就必须先把每一年的公司名单和报告文件对应起来,不能做一年拉一年。因为很多公司并不是连续披露的,缺失年份不是数据质量问题,而是披露行为本身的问题。这个区别在做计量的朋友那里应该很熟悉。
1.2 能用它回答哪些研究问题
纯文本形式的CSR报告语料,最擅长处理的是四类问题。
第一,披露内容的议题迁移。环境、员工、供应链、公益、公司治理等不同议题在报告里的占比变化,可以通过章节数量、关键词频次、句子长度这些指标来观察。比如“员工培训”这个词在2008年的报告里往往出现在“人力资源”小节,到2020年以后更多出现在“人才发展”或“员工成长”章节,这种位置迁移本身就是一种信号。
第二,披露质量与语调。通过情感词典、不确定词占比、积极措辞比例,衡量管理层在报告中的语气管理行为。CSR报告有一个特点,它不像财报那样有强制的格式要求,文字表达的自由度很高,这给语气分析提供了充足空间。一家企业在报告里频繁用“坚决”“全力”“持续提升”这类词,和另一家用“努力”“尽量”“将视情况”这类词,传达的信息密度完全不同。
第三,信息可比性。不同公司、不同行业之间的报告长度、章节结构、量化指标个数是否存在系统性差异。比如金融业报告的“绿色金融”章节普遍比制造业厚,制造业报告的“安全生产”章节又比金融业详细,这些都是可以做行业间对比的好素材。
第四,和企业行为的关联研究。把文本指标(披露倾向、环保词频、语调积极度)和财务指标、处罚记录、ESG评分做关联分析。我曾做过一个小测试:统计每家公司在报告正文里“生态”“绿色”“双碳”三个词每千字出现的次数,再和企业当年的环保处罚数据做相关性分析,能看到一些有意思的分化——部分公司文本上很积极,但实际处罚数量也在增加,这就是典型的“漂绿”风险信号。这类分析如果只有财务数据没有文本数据,根本做不出来。
1.3 谁最适合用这份数据
以我的实际接触来看,大概分三类人。
学术研究者是主力。研究ESG披露、公司治理、企业社会责任的硕博生和教师,需要一份带年份、带公司标识的标准化中文语料来验证假设。txt格式足够,配上自己写的清洗脚本,任何研究设计都跑得通。
量化分析师和策略研究员是第二类。他们希望把CSR文本转成可用因子,比如“报告语调变化”“环境词频增速”“社会责任相关句子占比”,然后塞进多因子模型或事件研究框架。这类用户对预处理的一致性要求很高,后面我会专门讲怎么构建面板数据。
第三类是NLP算法工程师和数据产品经理。他们需要一份干净、规模适中的中文长文本语料来训练或微调模型,比如摘要模型、信息抽取模型、文本主题分类模型。CSR报告的领域特征很强,连“质量”这个词在不同段落里的含义都不同,适合做有挑战性的微调训练集。
2. 为什么在这个场景里txt格式反而是最“皮实”的选择
2.1 PDF、Word、扫描件和txt的实际体验差异
很多人拿到数据的第一反应是:为什么不用PDF或Word?答案很简单:做文本分析时,PDF和Word都会给你增加好几层不必要的负担。
PDF的问题在于解析。一份排版复杂的PDF,用pdfplumber或PyMuPDF抽文字的时候,表格区域会乱,跨栏内容会被切断,双栏排版的报告抽出来以后阅读顺序完全是乱的。更麻烦的是,很多早期报告是扫描件,根本没有做OCR,抽出来全是空白,少量有图层的PDF里,文字也是嵌在注释块里的,提取时需要额外参数。
Word格式的问题在于兼容性。docx本质上是一个zip压缩包,需要解析document.xml文件,虽然python-docx能处理常规文档,但遇到带目录、文本框、域代码的复杂报告很容易丢内容。doc老格式就更麻烦了,需要额外转换工具,批量处理时经常出现“某个文件打不开”的情况。
而txt格式最大的优点是简单。它没有复杂的结构,没有格式标记,就是纯粹的字符流。对文本挖掘来说,结构越简单,处理越稳定。你在txt里拿到的内容和原始排版相比确实少了很多视觉信息,但做词频、情感、主题模型时,这些视觉信息本来就会被扔掉。打个比方,你研究一个人的观点,不需要纠结他今天穿什么颜色的衣服,txt直接帮你把“衣服”全部脱掉,只留下“观点”。
当然,txt不是没有缺点。表格里的量化指标、图表里的数值、图片中的文字信息,在txt里几乎全部丢失。而CSR报告里很多高价值内容恰恰是表格式数据,比如污染物排放数据、员工培训人次、慈善捐赠金额。所以txt适合做整体文本层分析,不适合做精确到指标值的抽取任务。这一点一定要在选题阶段就想清楚。
2.2 编码问题:txt唯一真正需要小心的地方
txt的“简单”是有代价的,代价就藏在编码里。我打开同一批数据时,遇到过三种编码的txt文件:UTF-8、UTF-8 with BOM、GBK。如果用Python带着encoding='utf-8'的固定参数去读,GBK文件会直接抛UnicodeDecodeError;如果文件带BOM,读取后第一个字符会多出一个不可见的\ufeff,这个字符不报错,但会让你后面所有字符串统计和词典匹配都出错。
处理办法不复杂。最稳妥的是写一个自动识别编码的函数,先用utf-8-sig尝试,失败后再用gb18030,gb18030是GBK的超集,能兼容绝大多数中文编码场景:
def smart_read_text(path): for enc in ['utf-8-sig', 'gb18030', 'utf-16']: try: with open(path, 'r', encoding=enc) as f: return f.read(), enc except UnicodeDecodeError: continue raise ValueError(f'无法识别编码: {path}')这个函数我几乎所有项目都会先用一遍。重点不是直接用它读完所有文件,而是一次性把所有文件的编码识别结果记录下来,看看数据源是否用了统一的编码。如果发现两三种编码混用,说明这批数据很可能是不同时间和来源拼接出来的,后续清洗时要格外小心。
还有一个容易被忽略的点:txt文件的行尾符不统一。老文件是\r\n,新文件是\n,个别文件是\r。在Windows上统计行数没问题,但到Linux、macOS上,用splitlines()会比split('\n')更安全,因为splitlines()能自动处理所有换行符类型。
2.3 从txt到结构化数据的最短路径
如果只是快速验证,不需要上数据库,直接用Python几行代码就可以把问题跑起来:
import pandas as pd def load_reports(file_list): rows = [] for file in file_list: text, enc = smart_read_text(file) rows.append({'file': file, 'text': text, 'len': len(text), 'enc': enc}) return pd.DataFrame(rows)然后再把file列拆成证券代码、年份、公司名:
df['code'] = df['file'].str.extract(r'(\d{6})') df['year'] = df['file'].str.extract(r'(20[0-2]\d)')这就是最原始但足够支撑分析的“语料面板”。后面所有更复杂的操作,都是从这个DataFrame出发的。下一节我会重点讲拿到这批txt之后,第一件该做的事其实不是建模,而是对数据本身做一次彻底的“验货”。
3. 拿到txt数据以后,建议先做完这三件事再做分析
3.1 检查编码BOM,顺便看文件是否完整
拿到一份更新至2024年的txt数据集,第一件事不是跑词频,而是写一个脚本把所有文件都过一遍smart_read_text,记录四个信息:文件大小、字符数、识别编码、是否正常读完。这一步相当于给数据建立基础台账,后面任何清洗操作都能追溯到源头。
我当时跑完就发现,有少数文件的字符数明显少于同年份其他文件。比如某家制造业公司2022年的报告只有三千字,而同行业同期报告平均有两万字以上。这种文件有两种情况:一是报告原文本身很短,早期报告确实存在这种情况;二是文件在传输或转码过程中被截断了。怎么区分?打开文件看结尾是否完整。正常的报告最后一个词基本是“特此报告”“特此发布”或者“工作持续改进”之类的收尾语,被截断的文件往往停在某个句子中间,甚至停在半个词上。
为了避免误判,建议输出一份“文件大小-字符数”分布表,按年份分组看中位数和异常值。凡是低于同年度中位数三分之一以下、字数又小于5000的txt,都要单独人工看一眼。这一步能省掉后面很多因为数据不完整导致的假结论。
3.2 文件名映射:证券代码、年份、公司名必须三位一体
文件名是这批txt数据能否被有效使用的关键。最理想的情况是类似“000001_平安银行_2023_社会责任报告.txt”的三段式命名,解析起来毫无压力。但实际数据集里会出现各种变体:有的文件名只有证券代码,有的公司名中间有空格或括号,有的年份字段写的是“2023年度”而不是“2023”,还有的干脆叫“未命名文档.txt”,这类文件如果不处理,后面所有按文件名做的分组都会出错。
我处理这类问题的经验是:不要直接改原始文件,而是先建一个元数据表。用正则从文件名提取code、year、name三个字段,提取失败的就放进“待人工处理”名单。然后拿这个元数据表和证券代码库做一次join,能对应上的文件保留,对不上的一对一核对。要避免的情况是“某个公司名字写错了,但没被发现”,这种错误在后续分析里几乎不可能看出来,因为单个文件不报错,只有做汇总对比时才可能出现轻微异常。
3.3 人工抽样校验:认真读五份报告,比写十次脚本管用
在开始写清理函数之前,我会先认真读五份不同年份的报告。这不是浪费时间,而是建立对数据的真实感受。读完你就会发现,很多报告的开头几页是封面、目录、董事长致辞,中间是各种“亮点汇总”,最后才是正式章节。如果对整个格式没概念,后面的章节切分和关键词统计很容易把目录和正文混在一起。
另外,不同年份的报告标题措辞差异很大,有的叫“企业社会责任报告”,有的叫“可持续发展报告”,还有的叫“社会责任暨ESG报告”。文件名里可能都写着“社会责任”,但正文标题根本不含这几个字。抽样校验时顺手把实际标题词记下来,后面做关键词词典时才能覆盖全面。
4. 清洗、去噪和切片:把原始txt变成可以计算的语料
4.1 基础清洗:不可见字符与全角半角统一
这一步像做饭前的洗菜。原始txt里会有大量无用的隐形内容:BOM头、全角空格、不间断空格、零宽字符、Windows行尾符,偶尔还有乱码替换符。我一般是先把这些全部清理掉,再统一全角半角数字和标点。
核心清洗函数大概长这样:
import re import unicodedata def clean_text(text): # 去掉不可见字符和零宽字符 text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text) text = text.replace('\ufeff', '').replace('\u200b', '') # 全角空格转半角 text = text.replace('\u3000', ' ') # 统一换行 text = text.replace('\r\n', '\n').replace('\r', '\n') # NFKC统一全角字符 text = unicodedata.normalize('NFKC', text) # 压缩连续空行 text = re.sub(r'\n\s*\n+', '\n\n', text) return text.strip()这里有个细节要特别提醒:unicodedata.normalize('NFKC', text)会把全角字母数字转成半角,但也会把一些特殊符号转成ASCII符号,对中文文本一般无害,可如果你的分析对象涉及“Ⅰ”“Ⅱ”“Ⅲ”这样的罗马数字,它会把“Ⅰ”转成“I”,导致后续把“第三章”识别成“第三I章”,正则匹配就容易出问题。所以建议在清洗前先判断有没有需要保留的特殊符号,或者清洗后做一次抽查,专门看罗马数字、破折号、顿号这些容易出问题的字符。
4.2 切分策略:按章节切还是按自然段切
文本分析里的“切分粒度”决定了后续方法的可用性。我常用的策略有三种,没有绝对的好坏,只看研究问题需要什么。
整篇作为一个文档,适合做报告级别的词频、情感、文本分类,比如计算整篇报告的情感得分、环境词密度,或者拿全文做LDA主题模型。这种粒度最省事,清洗完后直接统计。
按章节切分,适合研究“环境信息披露”“员工权益披露”等具体议题。做法是先准备目标章节标题清单,比如“环境绩效”“环境责任”“绿色运营”“生态环境保护”,然后用正则去匹配,从命中位置截取到下一个章节标题出现为止。问题在于中文报告的小标题格式极不统一,有“一、”“1.”“(一)”“第一章”等,不同年份同一公司的格式都可能不一样。我的做法是先用粗粒度规则预切分,把“一、”这种最通用的标题符号切成一级块,再在块内用二级关键词定位。
按自然段切分,适合做句子级或段落级的向量化。比如想知道“双碳”这个主题出现在哪些段落上下文里,或者统计积极词所在段落的其他词共现关系,这种粒度最有价值,但处理量也最大。我一般是先把报告按空行拆成段落,去掉少于五个字的碎片段,再保留含中文的段落。
4.3 报告特有噪声的去除
报告文本最大的噪声来源是封面、目录、页眉页脚和免责声明。这些内容混在主体文本里,做词频统计时会产生系统性的干扰。
封面和目录的处理:用正则把“目录”到“正文”之间的大段文本删除,但这只对格式规范的新报告有效。老报告的目录未必叫“目录”,有的叫“CONTENTS”,有的叫“本报告说明”,需要灵活处理。我的经验是先抽取二十份报告看看目录标题的实际用词,建一个同义词集合,再做删除。
页眉页脚的处理:很多txt转换工具会把页眉页脚重复插入每一行,导致公司名在一份文件里出现几十次。一个简单的判定方法是:如果“某某股份有限公司”这个完整名称在文件中出现超过20次,它大概率是页眉或落款,可以考虑替换成空字符串。但要注意,如果报告中确实反复提到公司全名(这很常见),也不要全部替换,最好只在句首位置做替换,或者统计时用正则排除前后没有中文标点的孤立公司名。
免责声明的处理:报告末尾“本报告所涉及的计划、目标等前瞻性陈述”这类套话,如果做情感分析,它们会带来稳定的乐观噪声。可以单独统计这类句子的数量作为“合规声明密度”指标,也可以在情感分析时直接把整段移除。到底怎么选,取决于你的研究问题,没有标准答案。
4.4 构建公司-年份-文本面板
清洗完之后,最终要产出一张数据表,每一行是一家公司在某一年的一份报告文本及若干衍生指标:
panel = pd.DataFrame({ 'code': codes, 'name': names, 'year': years, 'text': cleaned_texts, 'char_cnt': char_cnts, 'para_cnt': para_cnts, 'env_word_cnt': env_word_cnts, })我一般会额外计算几个派生字段:报告总字数、段落数、环境词频数、员工词频数、积极词比例、消极词比例、标题中的董事会辞字数。这样后续分析可以直接用这些字段,不用重新跑全文。注意一点,char_cnt最好用“去除空白后的字符数”,而不是len(原始文本),二者差距很大,尤其txt文件里可能包含大量换行和空格,如果不统一,跨公司跨年份的统计就不可比。
5. 从txt到结论:一条可以落地的完整分析链路
5.1 关键词词典的构建,不能从现成词表直接抄
用CSR文本做词频分析,词典是最关键的部分。词典不是随便找几个高频词,而是必须和研究假设强绑定。比如我想研究“双碳”目标提出后企业披露内容的变化,就准备三组词:环境类(环境、生态、低碳、绿色、节能、排放、污染)、双碳类(双碳、碳达峰、碳中和、碳足迹)、漂绿类(减排、环保投入、绿色转型)。然后在清洗后的文本上统计每家公司每年每千字中各组词的相对频次。
用相对频次而不是绝对频次是必须的。报告字数在2006-2024年间增长太快,绝对次数无法跨年份比较。举一个直观的例子:一份2010年的报告只有8000字,出现“环境”5次;一份2023年报告有30000字,出现“环境”12次。绝对次数已经翻了一倍多,但相对频次反而下降了。如果不归一化,任何时间趋势都会被报告篇幅增长这件事污染掉。
5.2 一个可以复现的时间序列过程
我利用这份txt数据做过一个简单验证,按年度统计环境相关词的平均相对频次,2006年时每千字大约只有1.8次,2015年是4.5次左右,到2023年接近9次。员工相关词的上升速度没有这么陡,说明环境议题在报告文字议程里的权重确实在逐步上升。这里要说明,这些数字只对我的抽样样本有效,不代表市场整体,但它演示了一个完整流程:原始txt → 清洗 → 词典匹配 → 统计 → 画趋势图。整个过程所有代码加起来不到100行,但每一步都依赖前面数据整理的质量。
如果要做更细的报告长度控制,还可以在相对频次的基础上再做一次回归调整,把char_cnt作为控制变量放进去,提取残差作为“额外环境关注度”。这比单纯看相对频次更接近“剔除篇幅影响后的披露强度”这一概念,研究里用起来也更有说服力。
5.3 情感分析:先跑词典法,再用模型法做稳健性检验
如果要分析报告语调,我建议先使用词典法,因为词典法可解释性强、稳定,且容易在论文里复现。通用中文金融情感词典未必完全适合CSR文本,需要手动补充CSR语境下的词:积极词如“高质量”“持续”“提升”“改善”“履行”“共创”,消极词如“缺陷”“事故”“处罚”“污染”“超标”。
一个容易出的问题是,CSR报告整体措辞偏正面,消极词本身出现频率就很低,直接计算“消极比例”会非常稀疏。这时候要换一个思路:统计“积极词比例减去消极词比例”得到净语调指标,或者统计“确定性修饰词”的密度来判断语气强度。另外,词典匹配对分词敏感,同一个词在不同报告里可能被分成“环境/保护”和“环境保护”,建议先把清洗后的文本做统一分词再建词典。
模型法用来做稳健性检验是很好的选择。比如用中文金融预训练模型对所有段落打分,然后和词典法得分做相关分析,相关系数在0.6以上就能相互印证。模型法的优势是能捕捉上下文,缺点是成本高、对超长文本不友好,而且结果的分词解释性差。前期用词典法跑通链路,后期用模型法做验证,这是最稳妥的组合。
6. 数据处理过程中踩过的五个典型的坑
6.1 乱码不止一种形态
我最早遇到乱码时,第一反应是编码识别错了,换编码重读后还是乱码。后来才发现,有一部分文件在生成txt时已经“二次转码”损坏了:源头文本是GBK,中间被当成UTF-8读取过一次,再用GBK写回,导致损坏无法恢复。这种文件没法用一个标准函数逆转,只能重新从原始来源下载。怎么判断文件是否可修复?尝试把文件内容重新编码回原始编码,比如把读取出来的字符串用gbk再编码一次,如果抛UnicodeEncodeError,说明内容里已经出现了无法映射的字符,大概率是已损坏。
6.2 更名公司会让时间序列断成两截
A股市场里公司更名非常常见,比如“XX科技”改名为“XX数智”,或者证券简称本身就带“ST”“*ST”前缀。如果只用公司名去匹配,一条时间序列会被断成两截。保险做法是优先用证券代码做唯一标识,同时保留一个“曾用名”字段,不要用名称直接做join。另外,同一家公司在更名前后,报告标题措辞也会变化,文件名解析时要把公司名字段存成“原始名称”和“标准名称”两个字段,避免后面统计口径混乱。
6.3 面板不平衡未必是数据缺失,可能是披露规则
早期做论文时,我发现2023年的公司数量比2021年少了一块,第一反应是数据不全,后来去核对披露名单才发现,有些公司确实没有发布当年报告,或者发布了但报告名称里不含“社会责任”四个字,而叫“可持续发展报告”“ESG报告”,所以没被过滤规则捞进来。遇到面板不平衡,先判断是真实未披露,还是命名差异导致的漏抓,再决定要不要补齐。这个判断直接影响后续分析方法的选型,如果是真实未披露,就需要用处理非平衡面板的计量模型;如果是漏抓,补数据就行。
6.4 文件名跟内容对不上,比没有数据更麻烦
有一次我按文件名里的年份分组统计,结果发现某一年环境词频整体异常偏高,查了很久才发现,那份文件名里写的是2022,但文本内容实际是2023年的报告,因为下载时被自动添加了“(1)”,我的正则提取年份时把后面的“2023”抓了出来,两个年份就这样混在同一批样本里了。从那次以后我加了人工抽检机制:随机抽取3%的文件,读取前200个字符和尾部50个字符,确认标题在报告内页显示的年份和文件名年份一致。这个抽检不费多少时间,但能避免一次非常严重的计量错误。
6.5 报告长度方差极大,所有跨样本比较都要谨慎
同一批数据里,有的报告只有6000字,有的是接近50000字的“精装版”。如果直接对比关键词绝对次数,头部公司天然占优势,很多结论都是假的。解决办法是全文都用相对频次、比例、百分比来比较,或者在模型里控制总字数变量。还有一个更细的点:不同行业的报告结构差别也很明显,银行业报告偏重责任金融,制造业偏重安全环保,跨行业做词频比较时最好做行业层面的分层检验,至少也要在回归里加行业固定效应。
说到底,整理这2006-2024年txt语料,真正的难点不在读文件和写代码,而在“把文本变成可对比的指标”这一层。如果你也是刚开始接触这批数据,建议从最简单的词频分析跑起,先把清洗和校验流程理顺,再逐步加入情感、主题模型这类进阶方法。流程跑通以后,你会发现这套语料能提供的价值远远超过最初的预期。