☰
Python年报PDF文本分析:从pdfplumber提取到TF-IDF关键词实战
2026/10/2 14:31:14 网站建设 项目流程

简介:面向财经数据爱好者与量化分析初学者的Python上市公司年报分析工程包,演示了从PDF年报解析、文本清洗、停用词过滤到关键词与情感分析的全流程,适合作为文本挖掘与NLP实践入门参考。资源共12个文件,核心为4个Python脚本:pdf2txt.py负责PDF转TXT,KeywordTrain.py与ReportAnalysis.py实现分词、TF-IDF关键词训练与结果整合,FileFilter.py辅助数据过滤;配套csv与xlsx格式的整合评分表,可直接查看分析输出。其余xml与iml为PyCharm工程配置文件,不影响代码阅读,49KB压缩包便于快速下载部署。已有7053人学习,通过该资源可掌握PyPDF2、jieba、sklearn等工具的年报文本处理思路,理解停用词过滤、关键词提取及评分表构建的完整链路,为开展公司基本面文本分析打下扎实基础。

1. 上市公司年报文本分析:一条输入 PDF、输出关键词的流水线

读上市公司年报原本是个体力活。一份 PDF 从几十页到三百页不等,里面混着财务数据、业务表述和风险提示,想横向对比几十家公司时,人工逐页翻基本不现实。这套基于 Python 的年报分析方案做的事情是一条流水线:先把 PDF 年报转成 TXT,再做分词和停用词过滤,最后用 TF-IDF 和词频统计提取关键词,完成一份可量化的文本分析。它解决的痛点不是用 AI 替代人读报告,而是把非结构化的 PDF 文档变成可统计、可排名的结构化词表,适合做行业研报、个股对比和财务舆情监控的从业者。这也是这篇笔记要拆开讲的东西:每一步怎么选型、参数怎么调、坑在哪里。

2. PDF 转 TXT:pdfplumber 选型与正文提取的三个关键点

2.1 PDF 结构决定转换方案:PyPDF2、pdfminer 还是 pdfplumber

年报 PDF 和普通电子书不一样,它的版面是典型的混合排版:目录、正文、表格、财务附注交错出现,页眉页脚每页重复,还有大量跨页表格。早期我用 PyPDF2 的extractText(),遇到纯文字页还算干净,一碰到表格就乱,文字顺序经常是左栏跳右栏,提取结果完全没法做后续分析。这类轻量库只做单向文本流抽取,不感知版面坐标,不适合年报场景。

pdfminer.six 底层能力强,能拿到字符坐标,但 API 偏底层,要自己处理布局推断,开发成本高。pdfplumber 是在 pdfminer 之上封装的库,把每个页面抽象成Page对象,提供extract_text()和extract_words()两个核心方法,前者拿整页文本,后者拿带坐标的单词列表,正好覆盖年报从正文到表格的两种提取需求。

我自己在三个库之间做过一轮对比,结论很直接:

库中文年报正文提取表格跨页处理上手成本推荐度
PyPDF2乱序明显差低不推荐
pdfminer.six可用但需二次处理中高备选
pdfplumber稳定可用extract_words排序低推荐

环境准备按常规走就行,Python 3.8 以上,pip install pdfplumber jieba,如果后面要做词云再补wordcloud matplotlib。这套资源里默认给的是 Python 3.10 环境下跑通的版本,依赖都锁在 requirements 里,直接用虚拟环境装不会有版本冲突问题。

2.2 按页提取正文:跳过页眉页脚和页码行

用 pdfplumber 提取整份年报的正文,最怕的不是提不出来,而是把页眉页脚、页码、公告编号这些每页重复的内容一并带进 TXT。如果这些噪音字符进了后面的分词和词频统计,会直接拉高无意义词的权重,污染关键词结果。我一般会按页循环提取,每页在处理时同步过滤三类噪音行。

import pdfplumber import re def is_noise_line(line: str, page_no: int) -> bool: """判断一行是否为页眉、页脚或页码""" # 纯数字页码行,可能带前后空格 if re.fullmatch(r"\s*\d+\s*", line): return True # 巨潮资讯、公告编号这类固定页眉 if "巨潮资讯" in line or "公告编号" in line: return True # 极端情况:页码和个别短词混在一行,长度很短时直接过滤 if str(page_no) in line and len(line) < 12: return True return False def pdf_to_txt(pdf_path: str, out_txt_path: str) -> int: """把年报 PDF 按页提取正文,返回写入的总行数""" page_texts = [] with pdfplumber.open(pdf_path) as pdf: total = len(pdf.pages) for page_no, page in enumerate(pdf.pages, 1): text = page.extract_text() if not text: continue lines = text.splitlines() clean_lines = [ln for ln in lines if not is_noise_line(ln, page_no)] page_texts.append("".join(clean_lines)) with open(out_txt_path, "w", encoding="utf-8") as f: f.write("\n".join(page_texts)) return len(page_texts)

核心是page.extract_text(),它返回当前页所有文本块按阅读顺序拼接的字符串。is_noise_line里三个条件各自解决一类问题:纯数字行对应独立页码;“巨潮资讯”“公告编号”是年报 PDF 最常见的固定页眉;第三个条件兜底处理页码和正文粘在同一行的情况。这类过滤规则一定要可配置,因为不同交易所的年报页眉格式不完全一样,拿到资源后先跑一遍,再根据实际输出调整正则。

2.3 跨页表格导致的文字乱序,用坐标排序解决

年报的合并财务报表动辄跨两三页。pdfplumber 的extract_text()遇到跨页表格时,会把表格拆分到各页,单独看每页没问题,拼起来后就出现“上页末尾接的是表头”“下页开头接的是上一页的单元格内容”这类顺序错乱。原因是extract_text()走的是文本流的先后顺序,而不是视觉位置顺序。

常见做法是放弃整页文本流,改用extract_words()拿到每个词的坐标盒,再按行坐标top分组、列坐标x0排序,从视觉位置上重建文本顺序。

def extract_sorted_text(page) -> str: """ 按视觉位置重建文本顺序: 先按 top 坐标分块,再按 x0 坐标从左到右拼接 """ words = page.extract_words(use_text_flow=True, keep_blank_chars=False) if not words: return "" # 按 top / 10 做行聚合,容错小范围行高偏差 lines = {} for w in words: line_key = round(w["top"] / 10) lines.setdefault(line_key, []).append(w) line_parts = [] for key in sorted(lines.keys()): words_in_line = sorted(lines[key], key=lambda w: w["x0"]) line_text = " ".join(w["text"] for w in words_in_line) line_parts.append(line_text) return "\n".join(line_parts)

use_text_flow=True让 pdfplumber 按文本流候选排序,对跨页恢复有帮助;round(top / 10)是把坐标按 10 个像素为一档聚合,年报正文行高通常在 15 像素以上,取 10 不会误合并相邻行。遇到表头跨页断裂时,这个函数能恢复大部分顺序,但表头会在新一页重复一次,这个在后续关键词分析阶段影响不大,如果做段落级分析就需要再按表格结构去重。

3. 停用词过滤:为年报语料定制三层过滤规则

3.1 为什么通用停用词表救不了年报

网上能下到的哈工大停用词表、百度停用词表,覆盖的是“的、了、而且、但是”这类通用虚词。放进年报场景会发现一个尴尬问题:过滤完之后,高频词表里站着的还是“公司”“报告”“年度”“本期”“管理层”这类词。它们是年报的语法骨架,但对分析一家公司的经营实质没有任何区分度。

真正要做关键词分析,必须给停用词表加上年报专有词汇。这些词的特征是全文高频、跨公司高频、信息量为零。我维护了一份固定扩充表,里面是“报告期、期末、本公司、董事会、监事会、万元、亿元、人民币、年度、本期”这类词,每年做新年报时再根据实际输出往里补。这个动作不能省,否则 TF-IDF 的排序结果会被这些词霸占前 20 名。

3.2 加载停用词表:通用表打底,自定义表补位

加载逻辑很简单,通用停用词表读入集合,自定义年报停用词叠加进去。集合去重查重的开销是 O(1),后续分词结果逐个判断时性能完全够用。

STOP_WORDS = set() def load_stop_words(generic_path: str, custom_words: list) -> set: """加载通用停用词表,并叠加自定义词""" global STOP_WORDS with open(generic_path, encoding="utf-8") as f: for line in f: w = line.strip() if w: STOP_WORDS.add(w) for w in custom_words: STOP_WORDS.add(w) return STOP_WORDS custom_stop = [ "公司", "报告", "年度", "本期", "本公司", "报告期", "期末", "管理层", "董事会", "监事会", "万元", "亿元", "人民币", "是否", "以及", ] STOP_WORDS = load_stop_words("hit_stopwords.txt", custom_stop)

hit_stopwords.txt是通用表路径,资源包里已经放好,直接用相对路径加载即可。custom_stop是我根据年报语料多次跑词频后归纳出的高频无意义词,你可以按自己分析的行业再扩,比如银行年报加“存款、贷款、利息”,制造业年报加“产能、生产线”。

值得注意的是“管理层”这类词要慎重,如果你做的是管理层讨论与分析专题,这个词反而是分析对象。我的处理方式是放一份白名单,在白名单里的词不过滤,优先级最高,这个机制在下一节和分词过滤一起实现。

3.3 分词结果的过滤规则:白名单优先,正则兜底

停用词表解决的是“词本身无意义”的问题,但年报分词结果里还有两类噪音需要过滤:一是长度异常的碎片,比如 jieba 把“数字基础设施”切成“数字/基础/设施”,单个“数字”长度等于 2 还能接受,单个“础”这种单字碎片必须清掉;二是混进来的英文、数字,虽然 pdfplumber 提取的纯中文年报里这类内容不多,但财务附注里经常出现“AAA”“BBB”这类占位字符。

过滤规则按顺序执行,白名单判断放在最前面,保证重要财务术语不被误伤。

import jieba import re # 白名单:业务实质强、不允许被停用词表或长度规则过滤的词 WHITELIST = {"净利润", "营业收入", "毛利率", "现金流", "资产负债率"} def filter_words(seg_list, stop_words, whitelist=WHITELIST): """对 jieba 分词结果做停用词过滤和正则清洗""" result = [] for w in seg_list: w = w.strip() if not w: continue # 白名单词直接保留 if w in whitelist: result.append(w) continue # 停用词过滤 if w in stop_words: continue # 长度过滤:2~8 个字符,单字碎片和超长噪声都排除 if len(w) < 2 or len(w) > 8: continue # 只保留中文字符组成的词 if not re.fullmatch(r"[\u4e00-\u9fa5]{2,8}", w): continue result.append(w) return result

WHITELIST是我踩过坑之后加的,最初把“利润”加进了停用词表,结果“净利润”“毛利率”全被连带过滤,关键词表里彻底看不到盈利指标。这块必须把完整词放进白名单,而不是靠长度规则去兜底。filter_words的输入是jieba.cut(text)的生成器,逐词判断,内存开销很小,几十万字的一份年报几秒就能跑完。

4. 关键词分析:TF-IDF 权重与词频统计的双视图

4.1 关键词不等于词频:为什么词频榜首不是真正的关键词

如果直接把词频 Top 20 当作年报关键词,结果大概率是“公司、报告、年度、业务、发展”这类词霸榜。这些词在每一份年报里都高频,恰恰说明它们没有区分度。关键词分析属于文本挖掘和人工智能应用里的基础模块,常用算法是 TF-IDF 和 TextRank。

TF-IDF 的核心逻辑是:一个词在单篇文档里出现频率高,但在整个语料集里很少出现,它才对这篇文档有代表性。放到年报场景里,“数据中心”在腾讯年报里高频、在白酒公司年报里很少出现,它的 IDF 值就高,能排进腾讯年报的关键词前列;而“公司”在每份年报里都高频,IDF 趋近于零,权重极低。TextRank 走的是图算法路线,把词当节点、共现关系当边,迭代计算词权重,更擅长抽文本骨架,但对短文档效果不明显,年报这种长文档里 TF-IDF 更直观可控。

4.2 用 jieba.analyse 自带 TF-IDF 提取关键词

jieba 内置了 TF-IDF 关键词提取实现,extract_tags默认就是 TF-IDF 模式,不需要自己手写 IDF 公式。这里要重点说两个参数:withWeight控制是否返回权重值,allowPOS限定词性,年报分析里建议只保留名词、动词和动名词。

import jieba.analyse def extract_keywords(text: str, topK: int = 30): """ 基于 TF-IDF 提取年报关键词 allowPOS 限制词性,过滤形容词、副词和无意义虚词 """ tags = jieba.analyse.extract_tags( text, topK=topK, withWeight=True, allowPOS=("n", "v", "vn"), ) # 权重保留四位小数,方便排序和输出 return [(word, round(weight, 4)) for word, weight in tags]

topK=30是经验值,30 个词足够覆盖一份年报的核心经营话题,再多就会出现长尾噪音。allowPOS是extract_tags内部调用词性标注的过滤口,n代表名词,vn是动名词,v是动词,这三类词覆盖了年报里绝大多数实质性信息。注意这里没做停用词过滤,因为extract_tags内部有底层的 IDF 加权,高频通用词会被权重压下去,但如果 IDF 语料是按常规文本训练的,年报特有词还是需要搭配第 3 章的停用词表做二次清洗。

4.3 词频 Top-N 和 TF-IDF 权重 Top-N 对照着看

TF-IDF 给出的是“这篇年报和其他年报比有什么特色”,词频给出的是“这篇年报在反复强调什么”。两种口径各有用途,我习惯把两份结果并排输出,人工扫一眼就能判断这家公司当期的主线业务。

from collections import Counter def words_topk(text: str, stop_words: set, topK: int = 20): """分词、过滤后统计词频 Top-K""" seg_list = jieba.cut(text) filtered = filter_words(seg_list, stop_words) return Counter(filtered).most_common(topK) # 示例:同一份年报,两种口径对比输出 text = open("annual_report.txt", encoding="utf-8").read() tfidf_words = extract_keywords(text, topK=30) freq_words = words_topk(text, STOP_WORDS, topK=20) print("=== TF-IDF 权重 Top10 ===") for w, wt in tfidf_words[:10]: print(f"{w}\t{wt}") print("=== 词频 Top10 ===") for w, c in freq_words: print(f"{w}\t{c}")

words_topk里先jieba.cut(text)做全模式分词,再走filter_words清洗,最后用Counter.most_common取频率最高的词。词频结果和 TF-IDF 结果往往重合度只有一半,重合的那部分词通常是“营收、研发、毛利率、现金流”这类当期经营核心;TF-IDF 独有的那部分是这家公司的个性业务词,比如“云计算、海外市场、新能源汽车”,这些词恰恰是后续写研报时最该展开的内容。

5. 避坑:PDF 转文本到关键词提取的五个常见踩坑

5.1 乱码和空文本:扫描版年报直接换 OCR 方案

现象:page.extract_text()返回空字符串,或者提取出来的是方块、乱码字符。

原因:年报 PDF 分两类,一类是原生电子版,文字层可提取;另一类是扫描后合成的图片版,每一页本质上是一张图片,pdfplumber 拿不到文字层。另外少数 PDF 用了字体子集嵌入,字符映射表不完整,也会导致提取结果错乱。

解决:先检测当前页是否有图片对象,有就说明是扫描页,直接跳过 pdfplumber 改用 OCR。常见做法是接入 PaddleOCR 或 Tesseract 识别该页图像再转文本。判断代码很简单,page.images非空且该页extract_text()返回空,就按扫描页处理。批量跑之前先抽 3 页人工确认,不要等全量跑完才发现整份是扫描版。

5.2 页码和页眉混进正文,词频榜首是“12”和“巨潮”

现象:词频统计结果里“1”“2”“12”这类纯数字排在前面,“巨潮资讯”“公告编号”反复出现。

原因:PDF 转 TXT 时每页的页码、页眉都进了正文,第 2.2 节的is_noise_line没覆盖到这类格式的页眉,比如“2023年年度报告”被当作页眉留在文本里。

解决:跑完 PDF 转 TXT 后,先看 TXT 开头 50 行,把页眉格式摸清楚,再回头补正则。最省事的兜底是在filter_words的正则里加一条:匹配^\d+$的直接丢弃;对“巨潮资讯”这种固定词,直接加进停用词表。这套资源里的页眉过滤正则按深交所和上交所年报各配了一份,换别的交易所先验证再复用。

5.3 停用词误杀“净利润”,关键词表里看不到盈利指标

现象:把“利润”加进停用词表后,“净利润”“毛利率”“营业利润”全被过滤,整份年报的关键词里找不到任何盈利相关词。

原因:jieba 分词会把“净利润”切成“净/利润”或“净利润”两种情况,停用词表按词匹配时,“利润”命中停用词,整段过滤逻辑就把包含它的词组一起清掉了。这类属于“子串误杀”,在中文分词场景里非常典型。

解决:维护白名单机制,filter_words的第一步先判断词是否在白名单里,命中就直接保留,不走停用词表。白名单里必须放完整词,“净利润”是词,“利润”是另一个词,两者要分开管理。这是我在第 3.3 节强调白名单优先的原因,也是这套资源里WHITELIST的核心价值。

5.4 jieba 把专业术语切碎,“销售毛利率”碎成三截

现象:TF-IDF 结果里出现“销售”“毛利”“率”这种残片,关键词表东一块西一块,没法直接使用。

原因:jieba 默认词典是通用语料训练的,对财务专业术语覆盖不足。“销售毛利率”在默认分词下会被切成“销售/毛利率”或“销售/毛利/率”,权重计算时三个碎片各自得分,反而把完整概念拆散了。

解决:加载自定义词典,把年报里反复出现的财务术语和业务专名写进 userdict 文件,每行一个词,格式是“词 词频 词性”,词频给 10 以上能提高被合并的概率。

# userdict.txt 示例 销售毛利率 10 n 资产负债率 10 n 经营活动现金流 10 n # 加载自定义词典,必须在分词前执行 jieba.load_userdict("userdict.txt")

词频数字不是越大约好,给 10 足够让它优先合并;词性标注用n即可,因为第 4 章的关键词提取里allowPOS已经限定到名词和动词。自定义词典需要在jieba.cut前加载,否则不生效。跑完一次后把新出现的碎词补进词典,这份词典会越用越顺手。

5.5 TF-IDF 结果里全是“期末”“报告期”“本期”这类时效词

现象:TF-IDF 权重 Top 10 里“期末”“报告期”“本公司”占了半数,和上一节 4.2 的预期不符。

原因:extract_tags内部使用的 IDF 语料是通用文本训练的,它对“期末”“报告期”这类财经时效词的 IDF 估计偏高。这些词在单份年报里反复出现,在通用语料中不常见,IDF 就大,权重被抬上去了。

解决:不能只依赖allowPOS,要把这部分词补进第 3 章的custom_stop停用词表。但坑 5.3 说过“利润”子串误杀的问题,所以补充停用词时要遵循“整词匹配、不是前缀匹配”的原则,只加“期末”“报告期”“本期”这类完整词,不加“期”这种单字。加完之后重新跑一遍,对比前后 Top 10,能看到明显的质量提升。

6. 进阶:用词云把关键词变成一张可视化大图

单看 Top 30 关键词表格,还是缺一张一眼能看懂的整体视图。词云正好补位:词的大小对应权重,位置分布天然反映主题聚集度。对一份 200 页年报提取出的关键词,生成一张词云图放在研报开头,比任何摘要都有冲击力。实现上直接用 wordcloud 库,中文场景唯一的坑是字体路径,不指定中文字体,所有汉字都会渲染成方块。

from wordcloud import WordCloud import matplotlib.pyplot as plt def build_financial_wordcloud(text: str, stop_words: set, output_png: str): """ 生成年报关键词词云 font_path 必须指向中文字体文件,否则中文会显示为方块 """ # 先分词过滤,再拼回空格分隔的字符串,这是 wordcloud 的标准输入 seg_list = filter_words(jieba.cut(text), stop_words) token_str = " ".join(seg_list) wc = WordCloud( font_path="C:/Windows/Fonts/simhei.ttf", width=1200, height=800, background_color="white", max_words=150, stopwords=stop_words, ) wc.generate(token_str) wc.to_file(output_png) plt.figure(figsize=(12, 8)) plt.imshow(wc, interpolation="bilinear") plt.axis("off") plt.tight_layout() plt.savefig(output_png, dpi=150)

font_path在 Windows 上用simhei.ttf或msyh.ttc,Linux 服务器上换/usr/share/fonts/下的中文字体路径,Mac 上用PingFang.ttc。max_words=150限制显示数量,太多词会让构图拥挤。stopwords参数在 wordcloud 内部还会有一次过滤,和第 3 章清洗是互补关系,不是重复。资源包里把字体路径做成了配置文件,换机器改一行即可。

用词云能不能直接判断一家公司的经营主题?我的经验是能。同一行业里,权重最高的词集中在“研发、客户、产品、渠道”的是成长型;集中在“成本、库存、管理、现金流”的是稳健型。这比读十页业务概要快得多。这个脚本跑成熟之后,我每次做年报分析都强制走一遍:PDF 转 TXT,抽样检查 3 页文本,跑词频和 TF-IDF 双表,最后出词云。这四步下来,一份年报的骨架已经摆在眼前了。这套方案的完整源码、停用词表和自定义词典都整理在资源包里,入口直接下载就是可跑通的工程。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询