
“你先看看我们最近五年的CSR报告有没有什么变化”朋友把一整个文件夹推给我里面是五份PDF转出来的TXT文档。我没有打开文档逐页读而是直接跑了一遍我自己写的Python词频分析工具。几分钟后屏幕上出现了五张可以横向对比的词频表。结果很直观五年前“社会责任”出现86次“可持续”出现23次到了去年“可持续”上升到了97次“社会责任”反而降到了41次。这种用词频率的变化恰恰反映了企业对外话语体系的转向。这便是我一直在维护的Python词频分析工具一个面向年报、CSR报告等企业长文本文档的智能统计软件。你只需要把TXT文档丢进指定目录它就能自动完成编码识别、中文分词、停用词过滤、自定义词典、同义词归并、词频统计、Excel导出和词云/柱状图生成。适合的人群很明确企业分析师、信息披露岗、ESG专员、做文本挖掘的学生以及那些不想反复给人解释“为什么不能直接用Excel数数”的Python开发者。这篇文章会把工具从需求设计到代码实现再到打包分发的完整过程都摊开讲包括我踩过的一些实在坑。1. 为什么我说“企业文本智能统计”不是简单数数很多人听到词频分析第一反应是“这不就是数词吗”如果只是统计“哪个词出现得多”用文本编辑器自带的查找功能也勉强能做。但企业年报和CSR报告这类文本真正值钱的地方从来不是“出现次数”而是次数背后能反映出的表达习惯、重点偏好和变化趋势。1.1 从一份CSR报告看出企业战略重心的迁移我朋友的问题是一个很典型的场景。企业社会责任报告的篇幅通常在几万字到十几万字人眼通读一遍需要几个小时读完之后脑子里留下的往往只有“好像提到了很多环保”“安全方面也说了不少”。这种印象式结论很难写进报告更没法支撑跨年对比。而词频统计给出的是一张可排序、可筛选、可计算的数据表。比如“安全”和“生产”连在一起出现的频率高于“安全”和“职工”说明安全语境偏向生产安全等某一年“安全”的搭配对象变成“数据安全”“信息安全”那说明企业的风险关注点已经扩展了。配合原句回溯整个报告的重心迁移过程就变得清晰可见。做工具之前我先定义清楚一个原则词频数字本身不意味着结论但它是结论的起点。所有统计结果应该能追溯到原文这样发现异常频率时我们可以点开上下文确认而不是被数字牵着走。1.2 词频分析能直接服务于哪些具体场景这个工具在实际使用中主要解决了四类问题。信息披露前的自检。企业发布年报或CSR报告之前合规和品牌同事需要确认某些关键承诺词有没有被覆盖比如“员工培训覆盖率”“碳排放强度”“社区公益投入”。词频表能快速显示这些词有没有出现出现频率和去年相比是否明显下降。下降本身不一定是问题但它会成为“为什么今年没强调”的提示。投资者关系中的口径分析。有些上市公司在不同年份的报告中对同一业务的叫法会变。以前叫“互联网”后来叫“数字化”再后来叫“人工智能应用”。把五年报告放进去跑一遍叫法变化的时间点一目了然这就是企业对外沟通意图的信号。同业对比。把同行业几家公司同一年的报告一起统计TopN关键词差异能反映各自的战略侧重。比如一家公司高频词是“成本控制”另一家是“研发投入”说法背后就是不同的经营重心。内部复盘。负责写总结材料的团队可以拿去年的汇报文本做词频统计检查整体笔墨分布是否和年度重点工作匹配。如果全年最重要的事情是“新产品上市”结果报告里“新产品”只出现两次那材料本身就需要调整。1.3 我为什么坚持用Python而不是现成软件市面上有不少文本分析工具部分还带GUI拖拽上传就能出词云。但我最终还是选择用Python自己写原因有三。可定制性。企业报告里到处都是专有名词、缩写、固定搭配。通用工具的词库是黑盒没有办法告诉它“请把‘环境、社会及管治’和‘ESG’当成同一个东西”。自己写代码停用词表、自定义词典、同义词表全部外置成文本文件业务方自己也能维护。数据不出本地。企业年报和CSR报告虽然不是绝密但在一些尚未公开披露的场景下仍有保密要求。把文档传到云端分析工具上总让人心里不踏实。Python脚本完全本地运行不上传任何数据这一条对企业用户非常重要。可重复、可追溯。用现成工具点几个按钮下一次别人问“这个结果怎么来的”很难回答。而脚本可以固定版本、固定参数每次跑的结果都有日志。需要复查时重新执行一遍就能复现。2. 技术选型与整体架构稳定才是第一位的这个项目真正开始写代码之前我先花了不少时间确定技术栈。词频分析听起来简单但要做到“能处理上百份长文档不出幺蛾子”选型必须偏保守。2.1 环境选择Python版本与依赖库如果你还在Python安装这一步打转我的建议是直接到官网下载Python 3.10或3.11安装时一定要勾选“Add Python to PATH”。在Windows上装完版本混乱的问题多半出在没勾这个选项。如果已经在用PyCharm也可以在项目设置里指定解释器不必纠结系统里到底有几个Python。依赖库方面我按“核心依赖”和“可选依赖”两组来管理。模块用途类型jieba中文分词核心pandas数据整理与导出核心openpyxlExcel写入核心chardet编码检测核心matplotlibTopN柱状图可选wordcloud词云生成可选PyInstaller打包exe发布用选择pandas是因为它处理DataFrame和Excel导出非常顺手不必自己去拼二维数组。选择jieba则是因为中文企业报告的分词任务里它是最成熟、最容易自定义的方案。正则表达式处理页眉页脚、批量遍历目录这些用标准库的re、os、pathlib就够了不需要引入额外框架。2.2 处理链路设计与数据流这个工具的整体流程我用一句话概括TXT文档进结构化数据出。具体链路可以拆成六段遍历指定目录收集所有TXT文件路径逐个文件做编码识别统一转成字符串文本清洗剔除页眉页脚、目录行、无意义短行分词并过滤停用词、无意义词性加载自定义企业词典和同义词映射表修正切分和归并用Counter聚合词频转成DataFrame导出Excel和图表。这条链路里最容易出问题的环节是第三步和第四步。企业报告里的“公司名称”在页眉页脚会反复出现直接删除会导致正文中的公司名也被误伤停用词表如果包含“企业”“公司”这类词又会把业务分析的核心对象给过滤掉。所以清洗和过滤规则我都设计成可配置、可回滚的不在代码里写死。2.3 为什么第一步先支持TXT而不是PDF或Word这是我被问过最多的问题“既然做企业文本分析为什么不直接解析PDF和Word”原因是稳定大于便利。PDF看起来通用但实际处理时水很深。很多企业年报是印刷版扫描后转PDF文字层并不存在解析出来的全是乱码即使有文字层pdfplumber对复杂表格、页眉页脚的处理也很消耗精力而且不同PDF生成工具产出的结构差异极大。Word文档用python-docx解析虽然不难但样式、页眉、批注会混进正文清洗成本反而更高。而TXT文档没有格式只有纯文本解析结果完全可控。所以我的策略是先把TXT这条链路打磨到极致后续要支持docx、PDF只需要在入口处增加一个“转成纯文本”的前置步骤词频分析核心代码完全不用改。有些用户问我“电脑没有txt文档怎么办”其实是Windows默认隐藏了扩展名新建一个文本文档输入内容后另存为UTF-8编码或者把Word文件“另存为”时选择“纯文本*.txt”就可以了。如果需要批量转换我推荐用WPS或Word自带的批量转格式功能把PDF/DOCX先导成TXT再交给分析工具处理。3. 核心实现从TXT导入到词频输出的完整流程进入代码环节。我按工具的执行顺序把核心部分拆开讲每一段代码都能直接复制到项目里跑通。3.1 编码检测与批量读取第一个坑就是编码。国内企业年报页面下载的TXT文件有的用UTF-8有的用GBK有的甚至直接用ANSI。如果你用固定编码去打开轻则中文乱码重则直接抛出UnicodeDecodeError。处理方式是用chardet检测前1024字节的编码概率再选择解码方式。import chardet from pathlib import Path def read_text_file(path: Path) - str: raw path.read_bytes() if not raw: return detected chardet.detect(raw[:1024]) encoding detected.get(encoding) or utf-8 try: return raw.decode(encoding, errorsignore) except LookupError: return raw.decode(utf-8, errorsignore)只检测前1024字节是因为对长文档做全量编码检测耗时较长而编码特征在文件头部基本已经能够判断出来。errorsignore是为了防止个别字节解码失败导致整个文件报废。这样做有一个代价某些生僻字可能被忽略。对于词频分析来说影响可以接受毕竟目标是统计高频词而不是逐字校对全文。批量读取时我用Path.rglob(*.txt)遍历目录包括子目录。这样用户可以把不同年份的报告放在不同子目录里工具会自动收集。3.2 分词、词性过滤与停用词表拿到全文后下一步是分词。这里我用了jieba.posseg因为它能返回词性方便过滤掉助词、标点、单位等噪音。import jieba import jieba.posseg as pseg STOPWORDS set() stop_path Path(stopwords.txt) if stop_path.exists(): STOPWORDS set(stop_path.read_text(encodingutf-8).split()) def tokenize(text: str): words [] for word, flag in pseg.lcut(text): w word.strip() if len(w) 2: continue if w in STOPWORDS: continue if flag.startswith(x) or flag.startswith(u) or flag in {p, c}: continue words.append((w, flag)) return words这段代码里有两个细节值得说明。第一不要把停用词表做得“大而全”。很多人习惯网上找一份几百上千行的中文停用词表直接套用结果发现“社会”“责任”“发展”这些核心业务词全被过滤了。我的做法是先用一份基础停用词表跑一遍输出Top100再把明显无意义的词如“报告期内”“本公司”“单位”“元”手动加进去。这样调出来的停用词表才贴合企业报告场景。第二词性过滤不能一刀切。有些关键词是形容词如“绿色”“智能”有些是动词如“治理”“转型”只保留名词会把信息丢掉大半。所以我的过滤规则只排除助词、叹词、介词、连词这类功能性词性其余全部保留把筛选决定权交给停用词表和自定义词典。3.3 自定义企业词典与同义词合并jieba对通用词汇的分词效果不错但遇到企业报告里的固定搭配就容易翻车。“公司治理”可能被切成“公司/治理”“环境绩效”可能变成“环境/绩效”。解决方式就是自定义词典。词典格式是每行一个词后面跟着词频和词性词频可以不填公司治理 100 n 环境绩效 100 n 可持续发展 100 l 董事会报告 20 n在分词之前调用jieba.load_userdict(enterprise_dict.txt)切分错误的概率会明显下降。同义词合并是另一个关键点。企业报告喜欢用缩写而且写法经常不统一。我的做法是维护一个同义词映射表SYNONYM_MAP { 可持续: 可持续发展, ESG: 环境社会管治, 环境、社会及管治: 环境社会管治, 双碳: 碳达峰碳中和, }统计时如果词在映射表里就把它归并到主词下。同时我会在调试模式里记录每个合并操作方便后续确认归并策略是否合理。合并是为了让口径统一但不要把“环保”和“环境”强行合并它们的语义范围不完全一致合并后反而会模糊分析结论。3.4 词频统计与导出结构设计词频统计最直接的方式是Counter但如果每个文档都维护一个Counter最后合并不太方便。我采取的做法是先遍历所有文档对每个文档的词列表计算Counter然后累加到全局Counter同时记录每个词出现在几个文档里即文档频率。from collections import Counter all_files [] # 元素为 (文件名, 文档词列表) freq_total Counter() doc_freq Counter() for fname, words in all_files: unique set(words) for w in unique: doc_freq[w] 1 freq_total.update(words) total_words sum(freq_total.values()) rows [] for word, count in freq_total.most_common(): rows.append({ word: word, count: count, doc_count: doc_freq.get(word, 0), share: round(count / total_words, 6), })增加doc_count字段是一个容易被忽略但很有用的设计。如果一个词只在某一份报告里爆量出现doc_count会很小说明它可能只是该报告的特殊写法而不是整个企业的普遍用语。结合count和doc_count两个维度能有效避免被单一文档带偏。导出时我会生成一个result目录写入Excel、CSV、词云图和TopN柱状图。Excel里至少包含三个sheet词频汇总、各文档词频明细、同义词归并日志。这样业务方不只看最终结论还能自行下钻到单文档里验证。4. 年报与CSR报告的专属处理长文档和报告用语的坑通用词频工具处理新闻、小说问题不大但放到企业年报场景里会冒出一堆特有麻烦。这一章我说几个在真实测试中反复踩过的坑。4.1 长文档性能优化逐段累加比一次性lcut更靠谱一份年报的TXT动辄十几万字如果把整个文本一次性丢给jieba.lcut内存和时间消耗都比较难看而且一旦某个段落异常很难定位问题。我改成按段落切分后逐段分析效果稳定很多。import re def analyze_long_text(text: str) - Counter: counter Counter() # 按常见标点和换行拆成小段过滤过长或过短无效块 parts re.split(r[。\n], text) for part in parts: part part.strip() if not part or len(part) 10: continue for word, flag in tokenize(part): counter[word] 1 return counter按段落切分“可持续”这类跨标点的复合词有可能被切断吗实际测试中只要企业报告没有把“可持续”拆到两个段落里就不会受影响。即便偶尔切偏最终统计结果也会被高频主词淹没不会产生显著性干扰。性能提升方面我实测一份15万字的报告逐段分词大约耗时20到30秒一次性lcut也是20到30秒差异并不大。真正的收益在后续清洗我可以只对指定段落执行“噪音过滤”而不是对整个文本做一遍正则匹配。4.2 页眉页脚与重复章节的清洗策略年报中的页眉页脚非常规整每页重复公司简称和页码。直接把它们从源文件里删掉行不行可以但容易误删正文里的合法表达。我的策略是“短句重复度筛选”。默认情况下超过30字的内容不是页眉页脚短于20字的行如果全文出现次数超过一定阈值才有资格进入候选噪音列表。然后由人工确认一次把确认的噪音句写入noise_lines.txt下次自动过滤。目录章节是另一个麻烦点。年报正文前面往往有十几页目录里面全是“第一章 公司简介”“3.2 经营情况讨论与分析”这类词如果不处理它们会挤占高频词的Top榜。我的方案是识别以“第”开头、包含“章/节”字样且行长度小于40字的段落标记为目录候选默认不参与统计。但这套规则必须允许开关因为有些报告会把“董事会报告”的章节标题写在正文里那本身就是重点内容删了反而失真。清洗的总原则是宁少勿多。词频分析不是做文本净化它的目标是保留有信息量的词。一个误删规则影响的是整份报告的可信度所以我选择用规则清除一批确定的噪音剩余的交给停用词表去处理。4.3 同义词归并与年度对比分析当年份跨度拉大时报告中同一个概念会有多种叫法。比如前几年写“环境保护”后来统一写“污染防治”早些时候写“员工关怀”后来改口叫“员工福祉”。如果不做归并年度对比时这些词会各算各的看不出趋势。我的做法是在同义词映射表之外再维护一张“年度对比主词表”。每个主词下挂若干变体统计时先把变体累加到主词上。输出年度对比时只显示主词。主词20192020202120222023可持续发展2331456297安全生产5852473944注意跨年对比要看相对频率而不是绝对次数。因为不同年份的报告篇幅不同2023年报告可能比2019年厚一半绝对次数自然会涨。所以我统计时会同时算一个share字段即该词在当年总词数中的占比。对比时优先看share的变化。5. 可视化与结果交付数据统计出来之后最难的不是画图而是让非技术背景的人也能快速理解结果。可视化这块我踩过的坑比较多说几个最有价值的。5.1 中文词云生成时最容易翻车的三个细节第一字体。wordcloud默认字体不支持中文直接生成会变方框。必须指定一个中文字体文件路径Windows上常用simhei.ttf或msyh.ttcLinux上常用wqy-microhei.ttc。我习惯于把字体文件拷贝到项目resources目录下避免依赖系统字体。第二数据格式。用generate_from_frequencies而不是generate(text)因为前者接收的已经是处理过的词频字典同义词合并、停用词过滤都在进入词云之前完成生成效果更干净。from wordcloud import WordCloud freq_dict {row[word]: row[count] for _, row in top_df.iterrows()} wc WordCloud( font_pathresources/simhei.ttf, width1600, height900, max_words200, background_colorwhite, collocationsFalse, ) wc.generate_from_frequencies(freq_dict) wc.to_file(result/wordcloud.png)第三要不要用自定义轮廓。网上很多教程教你用企业Logo或圆形的mask图生成词云看起来确实好看但要注意mask图必须经过处理纯白背景才会被当成透明区域。实际操作中如果没有合适的素材我建议直接用矩形把精力放在词频口径上而不是形状。5.2 TopN柱状图与热词趋势对比柱状图比词云更能准确表达数量关系。词云适合给人第一印象柱状图适合展示具体排序和差距。我一般会同时输出Top20横向柱状图因为报告里的词往往比较长横向排列不至于把字体压得很小。用matplotlib画中文图时不设置字体同样会乱码。两条核心设置import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, WenQuanYi Zen Hei] plt.rcParams[axes.unicode_minus] Falseaxes.unicode_minus这条不加负号在中文环境里会显示成方块。虽然柱状图不一定有负号但统一设上总没错。年度热词趋势对比我更喜欢用Excel表格展示而不是堆一堆折线图。因为5年报告对比时变量过多折线图密密麻麻反而不如表格清楚。真要看趋势我通常只挑三个主词画在同一张折线图里比如“数字化”“减碳”“风险”单独看它们的变化曲线。5.3 Excel报告让非技术人员也能自助查数最终交付给业务方的通常是一份Excel。我的Excel导出结构如下词频汇总sheet全部词的count、doc_count、share按count降序各文档词频sheet每一列是一个报告文件行是词单元格是该词在该报告中的频率同义词归并sheet记录每一条合并规则命中了哪些词便于审计清洗日志sheet记录过滤了哪些段落、为什么过滤。用pandas导出Excel非常简单import pandas as pd with pd.ExcelWriter(result/词频分析结果.xlsx, engineopenpyxl) as writer: df_summary.to_excel(writer, sheet_name词频汇总, indexFalse) df_docs.to_excel(writer, sheet_name各文档词频, indexFalse) df_syn.to_excel(writer, sheet_name同义词归并, indexFalse) df_log.to_excel(writer, sheet_name清洗日志, indexFalse)清洗日志sheet是很多人会忽略、但实际很救命的功能。后续分析方看到异常词频时第一反应就是问“这个词是不是统计错了”。清洗日志记录了所有被排除的内容能直接回答这个问题。6. 从脚本到可分发工具打包与持续更新的实战经验开发完脚本自己用和把它交给别人用完全是两回事。我陆续把这个工具打包成exe分发过几次也部署到Linux服务器上做过定时分析过程中积累了一些经验。6.1 PyInstaller打包exe的三个典型坑第一个坑是资源文件路径。脚本在PyCharm里跑得好好的打包成exe后一运行就报“找不到stopwords.txt”。原因是PyInstaller打包后程序临时解压到sys._MEIPASS目录当前工作目录并不是程序所在目录。解决办法是写一个资源路径函数import sys import os def resource_path(relative_path: str) - str: base getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base, relative_path)所有读取停用词表、自定义词典、字体文件的地方都通过resource_path去拼接路径。第二个坑是字体和词典没有打进去。PyInstaller默认不会把非代码文件自动加入打包需要在spec文件里显式添加datas。否则生成的exe虽然能启动但词云直接乱码自定义词典完全没生效。第三个坑是杀毒软件误报。用PyInstaller默认参数打包的exe经常被Windows Defender或其他杀软拦截。我后来改成onedir模式不是onefile误报概率低一些启动速度也更快。分发时把整个目录压缩成zip让对方解压使用实际体验反而不差。6.2 Linux服务器部署与自动化运行企业里如果要做定期分析比如每个季度把新发布的报告自动加入语料库跑一遍脚本更适合部署在Linux服务器上而不是放在某人电脑里手动点。Linux系统安装Python时有一条强烈建议不要动系统自带的Python用pyenv或venv单独创建项目环境。因为很多Linux发行版把系统Python和包管理工具绑在一起随便换版本可能导致yum或apt出问题。我一般用python3 -m venv venv创建虚拟环境再在虚拟环境里pip install依赖。服务器上跑中文词云需要注意中文字体缺失。一个常见的表现是matplotlib图里中文全是方块点。解决方案是安装开源中文字体apt-get install -y fonts-wqy-zenhei fonts-wqy-microhei安装后在代码里把中文字体路径指向新安装的字体文件。如果是离线服务器可以手动把字体文件放到/usr/share/fonts目录然后运行fc-cache -f。自动化运行则用cron设置定时任务。因为词频分析工具需要读取新增报告我会把输入目录和结果目录都做成绝对路径参数保证cron环境下的相对路径不会出问题。同时给脚本加一个日志开关每次运行输出到logs目录方便回溯。6.3 版本迭代给这个工具留好扩展点标题里写了“原创持续更新”说明这个工具不是写完一次就扔的。我总结出三条维护经验对你自己做长期工具也有参考价值。第一把配置外置。停用词表、自定义词典、同义词映射表、噪音行、目录识别规则全部放在conf目录下用文本文件或JSON保存。每次更新版本时业务配置不随代码变化用户升级后不需要重新调参。第二保持向后兼容。新版本增加功能时老版本生成的Excel如果不再兼容至少要先通过清洗日志明确提示。我在每次输出Excel时会生成一个工具版本号字段写在汇总sheet的末尾。这样拿到结果的人一眼就知道是哪个版本跑出来的排查问题方便很多。第三做回归测试。每次给工具加新功能前我会准备一份固定的样例报告集跑一遍并记录Top20词频结果。修改代码后再跑一遍对比Top20有没有出现明显不合理的词。若出现多半是词典或清洗规则被新代码影响了立即排查。这个习惯能避免“修了一个功能弄坏了另一个功能”的情况。整个工具目前还在迭代中接下来计划加入docx自动转换、扫描版PDF的OCR接口预留、以及基于规则的热词摘要生成。功能可以慢慢增加但底层的编码识别、清洗、词典、统计这些核心流程已经比较稳定我觉得这是它最有价值的部分。最后再分享一个我在实际使用中的体会词频分析工具的价值不在于代码写得有多花哨而在于它能不能把模糊的话语变成可讨论的数据。每次拿到一份新报告我会先跑一版默认参数然后根据结果去调整停用词表和词典这个过程本身就是在理解这家企业的表达方式。工具只是手段真正理解文本才是做词频分析最核心的能力。