ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python年报PDF文本分析:从pdfplumber提取到TF-IDF关键词实战

Python年报PDF文本分析:从pdfplumber提取到TF-IDF关键词实战 简介面向财经数据爱好者与量化分析初学者的Python上市公司年报分析工程包演示了从PDF年报解析、文本清洗、停用词过滤到关键词与情感分析的全流程适合作为文本挖掘与NLP实践入门参考。资源共12个文件核心为4个Python脚本pdf2txt.py负责PDF转TXTKeywordTrain.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 转 TXTpdfplumber 选型与正文提取的三个关键点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, encodingutf-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_flowTrue, keep_blank_charsFalse) 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], keylambda 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_flowTrue让 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, encodingutf-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, whitelistWHITELIST): 对 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 resultWHITELIST是我踩过坑之后加的最初把“利润”加进了停用词表结果“净利润”“毛利率”全被连带过滤关键词表里彻底看不到盈利指标。这块必须把完整词放进白名单而不是靠长度规则去兜底。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, topKtopK, withWeightTrue, allowPOS(n, v, vn), ) # 权重保留四位小数方便排序和输出 return [(word, round(weight, 4)) for word, weight in tags]topK30是经验值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, encodingutf-8).read() tfidf_words extract_keywords(text, topK30) freq_words words_topk(text, STOP_WORDS, topK20) 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_pathC:/Windows/Fonts/simhei.ttf, width1200, height800, background_colorwhite, max_words150, stopwordsstop_words, ) wc.generate(token_str) wc.to_file(output_png) plt.figure(figsize(12, 8)) plt.imshow(wc, interpolationbilinear) plt.axis(off) plt.tight_layout() plt.savefig(output_png, dpi150)font_path在 Windows 上用simhei.ttf或msyh.ttcLinux 服务器上换/usr/share/fonts/下的中文字体路径Mac 上用PingFang.ttc。max_words150限制显示数量太多词会让构图拥挤。stopwords参数在 wordcloud 内部还会有一次过滤和第 3 章清洗是互补关系不是重复。资源包里把字体路径做成了配置文件换机器改一行即可。用词云能不能直接判断一家公司的经营主题我的经验是能。同一行业里权重最高的词集中在“研发、客户、产品、渠道”的是成长型集中在“成本、库存、管理、现金流”的是稳健型。这比读十页业务概要快得多。这个脚本跑成熟之后我每次做年报分析都强制走一遍PDF 转 TXT抽样检查 3 页文本跑词频和 TF-IDF 双表最后出词云。这四步下来一份年报的骨架已经摆在眼前了。这套方案的完整源码、停用词表和自定义词典都整理在资源包里入口直接下载就是可跑通的工程。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进