
简介这是一套基于Python构建的上市公司年报文本分析工程面向金融数据分析师、NLP初学者及文本挖掘开发者帮助解决年报等PDF非结构化文档转文本、噪声过滤、关键词提取与深层语义分析的完整流程。压缩包共12个文件约49KB核心为4个Python脚本分别负责PDF转TXT、停用词过滤、关键词训练和报告分析同时附有整合打分CSV/XLSX结果数据及XML/IML工程配置项目结构清晰便于直接运行和二次改造。资源已有7053人学习适合作为中文财经文本挖掘的实战范例。实现层面工程结合PyPDF2完成格式转换利用jieba与NLTK做分词和停用词过滤再借助TF-IDF/TextRank提取核心关键词并引入情感分析、主题建模与词云可视化。借助这些模块读者能从海量年报中快速定位经营要点与趋势并将整套流程迁移至公告、研报等各类长文本分析场景。1. 上市公司年报文本分析把300页PDF变成一页关键词值不值下载一份年报PDF很容易真正读完却要花一整晚。基于Python的上市公司年报文本分析要解决的就是把“读”变成“算”先用pdfplumber把PDF转成txt再用jieba分词和停用词过滤把“本公司”“报告期”这类套话丢掉最后用TF-IDF和TextRank算出这份年报真正在强调哪些词。这套流程不需要GPU普通办公电脑就能跑完沪深两市上千份年报产出的是“这家公司今年把什么放在嘴边”的可量化信号。适合自己做基本面研究的人、写尽调的从业者以及想从公开文本里挖特征的量化方向。下面按我实际跑过的路径把代码和坑一次讲清楚。2. 用pdfplumber把年报PDF转成txt先过字符层再决定要不要OCR年报PDF有个特点页数多、版面杂、来源乱。有的是交易所官网披露的正式版本文字层干净有的是券商转发或第三方扫描后重新合成的首页是图片正文是有文字但错位的“伪文本”。第一步必须做好探测否则后面分词、提取关键词全是白做。2.1 为什么选pdfplumber而不是PyPDF2PyPDF2的extractText()函数看着能用实际处理A股年报这种带目录、带页码、带跨页表格的文档时经常丢字、乱序。pdfplumber基于PDFMiner改造最大的优势是保留每个字符的坐标信息页面里的表格、页眉页脚都可以按坐标区域单独处理这对年报特别关键——财务报表附注占了后半本全是表格如果整体提取文字顺序会穿插成乱码。另外一个常见选择是PyMuPDF也就是fitz它提取速度快但字符顺序在某些双栏版面里不够稳。我的习惯是pdfplumber为主PyMuPDF放在后面做交叉验证两个库同时装处理异常时互为参考。安装也很直接pip装pdfplumber、PyMuPDF两个包即可不带额外系统依赖。2.2 最小可跑代码按页提取并写出纯文本先把整本年报读一遍逐页调用extract_text()中间插入页码标记。这个标记很重要后面定位“管理层讨论与分析”章节时要用。import pdfplumber def pdf_to_txt(pdf_path, txt_path): all_text [] with pdfplumber.open(pdf_path) as pdf: total_pages len(pdf.pages) print(f总页数: {total_pages}) for i, page in enumerate(pdf.pages): text page.extract_text() if text and len(text.strip()) 10: all_text.append(f PAGE {i1} \n{text}) else: all_text.append(f PAGE {i1} \n[疑似空白页或扫描页]) with open(txt_path, w, encodingutf-8) as f: f.write(\n\n.join(all_text))这段代码的逻辑分三步打开PDF逐页提取文本过滤掉长度不足10个字符的垃圾页。为什么设10这个阈值因为年报封面页通常只有“XX股份有限公司 2023年年度报告”十几个字再少的大概率是有文字层的空页。实际运行时如果某页提取结果是空字符串不要直接跳过先写入占位标记后续检查时能看到哪些页码出了问题。extract_text()有一个常用参数x_tolerance默认值是3作用是在同一行内合并字符时允许的横向间距。年报正文是小五号字默认值没问题但遇到表格里分开的单元格经常需要调大到5甚至8。我当时处理一份农商行年报时默认参数把“净利润 12.35 亿元”的“12.35”单独拆成一行调x_tolerance8后恢复正常。所以第一版脚本可以加上参数page.extract_text(x_tolerance4)。如果版面是双栏比如“管理层讨论与分析”里偶尔出现的两栏排布直接提取会左边一栏读完才读右边。这时候用crop按坐标切半页分别提取with pdfplumber.open(pdf_path) as pdf: page pdf.pages[10] # 假设第11页是双栏 left page.crop((0, 0, page.width / 2, page.height)).extract_text() right page.crop((page.width / 2, 0, page.width, page.height)).extract_text()crop接收一个四元组(x0, y0, x1, y1)单位是PDF的点不是像素。切半页后生成两个文本块再按顺序拼接。我通常先跑一次整体提取看哪几页顺序明显错乱再针对这几页用手工坐标处理不搞全量自动化。2.3 先跑一个探测脚本判断这份PDF是文本型还是扫描型扫描版年报没有文字层pdfplumber提出来永远是空。在投入写后续分词代码之前先用几行脚本看首页import pdfplumber with pdfplumber.open(annual_report.pdf) as pdf: first pdf.pages[0] text first.extract_text() or print(首页文本长度:, len(text.strip())) print(首页图片数量:, len(first.images)) if len(text.strip()) 30 and len(first.images) 0: print(结论: 扫描版或图片封面需要OCR) else: print(结论: 文本版可继续)判断依据是正常年报封面的矢量文字提取出来至少有三五十个字符扫描版虽然视觉上是文字但PDF层面只有图片对象。这里有个经验值首页文本长度小于30且图片数大于0基本可以断定至少封面是扫描的。还要看正文页有些年报只有前几页是扫描后面是干净的文本层这种情况就是混合版只对特定页跑OCR。OCR兜底方案我用pdf2image加pytesseract。pdf2image把PDF页转成PNGpytesseract调Tesseract识别from pdf2image import convert_from_path import pytesseract images convert_from_path(annual_report.pdf, dpi200, first_page1, last_page5) for idx, img in enumerate(images): text pytesseract.image_to_string(img, langchi_sim) print(f第{idx1}页OCR结果前200字:, text[:200])注意Tesseract的中文语言包需要单独安装只装主程序后直接指定langchi_sim会报错。Windows下安装时勾选Chinese Simplified语言包macOS用brew install tesseract-langLinux按发行版仓库装tesseract-ocr-chi-sim。dpi参数不用追求高。年报正文五号字200dpi足够识别300dpi识别时间直接翻倍。实际经验是一页扫描件大约消耗2到5秒整本300页扫描版跑下来要20多分钟所以只对探测出来的扫描页做OCR不要全量。3. jieba分词加停用词过滤年报里的“套话”必须单独处理PDF转成txt只是第一步。年报文本和新闻稿、股吧评论最大的区别是套话密集一份200页年报里“本公司”“报告期”“人民币”“万元”这些词会出现几百上千次。它们对理解公司经营没有任何信息量却在后续关键词分析里霸占权重。这一章解决的就是这个问题。3.1 停用词表从哪来通用表打底年报专用表补齐中文NLP常用停用词表在开源社区有很多版本哈工大停用词表、百度停用词表、四川大学机器智能实验室停用词表都有人维护网上搜“中文停用词表”能直接拿到txt文件。这些通用表覆盖了“的、了、是、在、和、就、都”这类虚词但覆盖不了年报专用噪声。我实际跑下来的经验是必须维护一张独立的年报停用词表。通用表过滤完剩余的词频Top10里依然全是“报告期”“人民币”“万元”“本期”“期末”“同比”“其中”这类词。它们不是错误是年报文体自带的噪声。把出现频次高、又不承载公司个性信息的词统一收进自定义停用词表比在通用表里手工加词要省事得多。3.2 过滤代码分词、去停用词、只保留有信息量的词jieba是中文分词的事实标准不需要额外配置模型。完整的分词过滤函数我在本地持续用了很久代码收敛成下面这样import jieba import re def load_stopwords(path): words set() with open(path, encodingutf-8) as f: for line in f: w line.strip() if w: words.add(w) return words STOP load_stopwords(stopwords_zh.txt) ANNUAL_STOP { 报告期, 本公司, 人民币, 万元, 本期, 期末, 同比, 亿元, 以及, 其中, 主要, 相关, 重大, 事项, 报告, 年度, 公司, 股份, 有限, 股东, 董事会, 监事会 } def cut_and_filter(text): text re.sub(r\s, , text) words jieba.lcut(text) return [ w for w in words if w not in STOP and w not in ANNUAL_STOP and len(w) 1 and not re.fullmatch(r[0-9.,%、:;()], w) ]这里几个参数值得细说。len(w)1把单字词全部丢掉“增”“减”“扩”这类单字动词单独出现时没有分析价值但“增长”“减少”是双字词会保留。正则fullmatch匹配的是“纯数字和标点符号串”比如“12.35”“3.28%”“1”这些在年报里到处都是出现频率高但没有个股区别度。注意我用了fullmatch而不是search所以“5G”“AI”这种含字母数字的词不会被误杀。为什么把“公司”“股份”“有限”这些看起来是正常词的也加进停用表因为年报抬头是“XX股份有限公司”每页页眉都带公司全称不滤掉它们词频统计和关键词提取的结果里它们永远排前三淹没了真正有分析价值的业务词。3.3 自定义词典把公司名和行业术语锁进分词结果jieba对新词有HMM识别能力但对长专名很不稳。比如“磷酸铁锂”默认分词可能切成“磷酸”“铁锂”“研发投入”可能被切成“研发”“投入”倒是不算错但后续关键词提取时“研发投入”作为一个整体信号比“研发”“投入”分开要有意义。年报里这类复合词非常多解决办法是加自定义词典jieba.add_word(磷酸铁锂) jieba.add_word(研发投入) jieba.add_word(储能业务) jieba.load_userdict(annual_dict.txt)add_word适合临时加几个词load_userdict适合维护一个持续累积的词典文件。userdict的格式是一行一个词可以带词频和词性用空格分隔比如“研发投入 100 n”。词频参数给高一点比如默认词频是10专名给50到100能有效防止被其他路径切碎。词性用n表示名词关键词分析时可以用词性过滤。我的习惯是拿到一份新年报先做一次快速预扫描用head命令看txt前20000字把出现3次以上的产品名、项目名、子公司名挑出来加进词典。这个过程第一次做比较费劲但词典是积累的处理完三五十家公司后常见的行业术语基本都覆盖了。4. 关键词分析与文本分析用TF-IDF和TextRank抓年报的“题眼”停用词过滤完之后真正能进分析的文本也就剩三分之一。下一步是从清洗后的文本里提取关键词回答“这家公司今年在强调什么”。我用两套算法并行互相校验而不是只信一个结果。4.1 TF-IDF和TextRank在年报场景怎么选TF-IDF的核心思想是“在一篇文档里出现频率高、但在整个语料里很少出现的词更有代表性”。jieba的extract_tags内置了一个IDF词典这个词典是在大量通用语料上统计出来的所以它天然会压制“增长”“减少”“发展”这类通用动词给“磷酸铁锂”“应收账款”“商誉减值”这类业务词更高的权重。单文档分析时这个内置IDF表足够用不需要自己建语料库。TextRank不一样它不依赖外部语料只看文档内部的词共现关系。它的逻辑是如果一个词和很多其他词共同出现那它就是文本里的“枢纽词”。年报的句子普遍很长动不动五六十个字“本公司报告期内实现营业收入XX亿元同比增长XX%”。这种长句里TextRank会把“营业收入”和“同比”这类紧邻词关联度算得很高。正因为两套算法的统计原理不同同一份年报跑出来的Top20关键词重合的部分基本就是这份年报稳定的“题眼”。4.2 提取代码topK、withWeight、allowPOS三个参数调出可用结果先看最直接的用法from jieba.analyse import extract_tags, textrank with open(annual_report.txt, encodingutf-8) as f: text f.read() # TF-IDF tfidf_tags extract_tags( text, topK30, withWeightTrue, allowPOS(n, vn, nr, ns, nt) ) # TextRank tr_tags textrank( text, topK30, withWeightTrue, allowPOS(n, vn, nr, ns, nt) ) for word, weight in tfidf_tags[:15]: print(f{word}\t{weight:.4f})三个参数是调出可用结果的关键。topK控制返回条数年报300页文本信息量大我一般取30少了漏掉尾部信号多了全是长尾噪声。withWeightTrue让结果带权重值后面做按年对比时必须有权重才能看出“这个词今年被强调的程度上升了”。allowPOS是词性白名单这个参数直接决定结果质量。年报里真正有价值的信号词主要是名词和名动词业务名、财务科目名、风险词都是名词“增长”“下降”“优化”是动词。只取n、vn、nr、ns、nt就是名词、动词名物化、人名、地名、机构名能把“了”“在”“进行”这类垃圾挡在外面。我早期不传allowPOS时Top20里混进了“进行”“实现”“持续”限词性后明显干净。还有一个容易被忽略的入口jieba.analyse.set_stop_words()。这个方法会直接影响extract_tags内部的候选词过滤。先把通用停用词表传进去再调用extract_tags效果等同于在代码里手动过滤但执行效率更高from jieba.analyse import set_stop_words set_stop_words(stopwords_zh.txt)4.3 从关键词到分析信号词频统计和重点词命中关键词提取适合快速浏览但要落到具体结论还得回到词频统计。把年报全文分词过滤后用Counter统计每个词的绝对出现次数from collections import Counter words cut_and_filter(text) counter Counter(words) # 全量高频词 for word, freq in counter.most_common(50): print(word, freq) # 重点信号词命中检测 MARKERS [应收账款, 商誉减值, 诉讼, 毛利率, 研发投入, 存货跌价] signals {w: counter.get(w, 0) for w in MARKERS} print(signals)为什么用dict.get而不是for循环里逐个count因为Counter已经把词频算好了get只是查表一次遍历O(n)就能把全部重点词查完。这里列出的MARKERS是我做财务风险排查时常用的信号词应收账款和存货跌价对应资产质量商誉减值对应并购后遗症诉讼对应合规风险毛利率和研发投入对应成长质量。它们不一定出现在TF-IDF的Top30里但一旦出现就要注意属于“低频但致命”的词。4.4 文本分析的一个雷别对年报做通用情感分析很多人做完关键词提取后下一步就想做情感分析这是年报分析最容易翻车的地方。通用情感词典和情感分类模型是在新闻、评论、社交媒体语料上训练的年报是高度规范化的公文语言“重大”“显著”“影响”“变化”这些词在通用词典里可能带情感极性在年报里全是中性表述。还有一个更隐蔽的问题年报里的负面信息不以情绪词呈现。“计提商誉减值准备”“涉及诉讼”“存货跌价损失”在语义上不含任何情感词但每一个都是真金白银的利空信号。所以对年报这类文本正确做法是维护一套“财务风险信号词表”按命中次数打分而不是去做情感极性判断。关键词分析的价值在于把注意力引到这些词上再做人工复核。5. 年报文本分析避坑清单5个我真实踩过的坑这套流程我断断续续跑了三四十家公司的年报每一步都有过翻车经历。下面这些坑按“现象→原因→解决”写清楚照着能省掉大量试错时间。5.1 提取出来全是空白扫描版PDF必须走OCR现象pdfplumber跑完一整本年报txt文件里全是“ PAGE N \n[疑似空白页或扫描页]”正文一个字没有。原因PDF是扫描件本质是一堆图片没有文字层。这在上市时间超过十年、早年披露不规范的公告里非常常见。解决用2.3节的探测脚本先判断每页是否有文字层对空白页单独调Tesseract做OCR。注意OCR一定要配langchi_sim并且先确认Tesseract的中文语言包已安装。还有个细节如果某页既有少量文字又有大量表格优先提文字层表格区域可以剪裁后单独OCR不要整页OCR把已有文字洗一遍识别出来的错字比原文字层更麻烦。5.2 提取出来乱码和错字字体编码损坏换库交叉验证现象某些页提取出来是“锟斤拷”“烫烫烫”或者大段空字符明显不是正常中文。原因原始PDF在生成时字体被裁剪或子集化字符的ToUnicode映射表缺失pdfplumber按映射表解析时只能输出占位符。解决先用PyMuPDF做交叉提取fitz的文本解析路径和pdfplumber不一样经常能救回一部分页面。如果两个库都乱码证明PDF自身编码已坏直接对这两个页面走OCR。这里我踩过教训当时只信pdfplumber把某份年报的财务附注全部当成垃圾文本跳过结果漏掉了大额存货跌价的信息。5.3 全文本跑太慢先定位“管理层讨论与分析”再切片现象一份200页年报从PDF读取到分词结束跑了十几分钟关键词结果还被财报附注里的科目名干扰。原因年报后半部分是资产负债表、利润表附注大量重复科目名和数字既慢又不产生经营信号。“管理层讨论与分析”才是年报中信息密度最高的章节但它在不同年份、不同公司的年报里页码完全不同。解决写一个定位函数先在每页前200个字符里搜“管理层讨论与分析”找到起始页码再从该页往后取40页作为分析范围跳过封面、目录和财报附注。def locate_section(pdf_path, keyword管理层讨论与分析): with pdfplumber.open(pdf_path) as pdf: for i, page in enumerate(pdf.pages): text page.extract_text() or if keyword in text[:200]: return i return None start_page locate_section(annual_report.pdf) print(管理层讨论与分析起始页:, start_page)为什么只搜前200字符因为页眉和章节标题都出现在页面顶部前200字符足够判断还省了整页提取时间。定位后处理范围设为start_page到start_page40页我实测覆盖了绝大多数公司“管理层讨论与分析”的全部内容。5.4 过滤完还有“报告期”“本期”要维护年报噪声词表现象通用停用词表已经加载分词过滤也做了但词频Top10依然是“报告期”“本期”“期末”“同比”“万元”。原因这些词在通用语料里出现频率不高所以不在通用停用词表里但年报文体里它们几乎每段都出现属于“年报专属停用词”。解决维护一张独立的ANNUAL_STOP表并在每次跑完一批年报后把结果里出现的高频无意义词追加进去。我会把每年新增的噪声词单独存成一个txt按月累积。这个表的规模控制在200个词以内太多会误伤真正有分析价值的词比如“合作”就不能加因为“战略合作”是判断公司业务拓展的重要信号。5.5 关键词全是“公司”“项目”“发展”用词性限制和TextRank兜底现象extract_tags返回的Top20里“公司”“项目”“发展”“业务”占了五六个业务词反而排不进来。原因这些词在本篇文档里频次极高撞上了内置IDF表的盲区另外没有限制词性动词和抽象名词挤占名额。解决第一轮跑的时候就把allowPOS限制为(n, vn, nr, ns, nt)词性过滤能挡掉一半垃圾。如果仍有顽固噪声我一般是“两轮迭代”第一轮提取后把前10个明显无意义的词手动加入set_stop_words再跑第二轮结果显著变化。不要试图一步到位年报文字太规范噪声词是分布式的迭代清洗比一次清洗更可控。6. 让关键词结果可验证的进阶用法按年度对比把信号写成结构化数据单看一份年报的关键词很难判断它到底是常态还是异动。连续看同一家公司三到五年年报关键词的变化轨迹才有真正的情报价值一家公司前两年在强调“产能扩张”今年突然变成“降本增效”“现金流”说明经营策略转向了。我的做法是把每年提取结果写进CSV按年份、词、权重三列存储。用utf-8-sig编码这样用Excel打开中文不会乱码import csv from jieba.analyse import extract_tags years [2020, 2021, 2022, 2023] with open(keywords_by_year.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([year, word, weight]) for year in years: with open(f{year}.txt, encodingutf-8) as txt: content txt.read() for word, weight in extract_tags( content, topK20, withWeightTrue, allowPOS(n, vn, nr, ns, nt) ): writer.writerow([year, word, round(weight, 4)])生成CSV后用pandas按年份透视一下就能看到权重变化。重点观察两类词一是往年没有、今年新出现的词比如“仲裁”“减值”这是公司叙事的转折信号二是权重连续两年上升的词说明公司在反复强调某个方向。我自己的习惯是把关键词对比结果和财务数据并排看验证信号是否在财报里落地比如“诉讼”权重升高就看附注里的预计负债和营业外支出是否有对应数字。这样做的好处是文本分析不再是黑匣子每个信号词都能找到年报里的原始出处。希望帮到你。本文还有配套的精品资源点击获取