ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python实战:PDF关键词提取与TF-IDF分析全流程

Python实战:PDF关键词提取与TF-IDF分析全流程

1. 从一份PDF到一张关键词地图:这个项目到底在解决什么问题

手里有一堆PDF,想快速知道每份文件到底在讲什么、哪些词是这份文件区别于其他文件的“指纹词”——这个需求在文档管理、竞品分析、学术文献综述、合同归档等场景里反复出现。我最早接触这类需求是在做一批技术白皮书的归档整理,几十份PDF混在一起,人工翻目录效率极低,于是就想用Python把这件事自动化:读取PDF文本,做分词,算TF-IDF,把每份文档权重最高的词拎出来,形成一张“关键词地图”。

这个项目的核心链路其实就四步:PDF文本提取 → 中文分词 → TF-IDF计算 → 结果排序与输出。标题里提到的“plumer”大概率是“plumber”的拼写变体,在PDF处理语境下,它指的是一类负责“管道式”抽取PDF内容的工具或思路——把PDF当作数据源,通过一条处理管道把文本、表格、元数据逐层抽出来。不管具体用哪个库,底层逻辑是一致的:先把非结构化的PDF变成结构化文本,再交给统计模型去算权重。

适合谁来参考这篇内容?如果你有Python基础,能看懂列表和字典操作,但没怎么碰过PDF解析和文本挖掘,那这篇正好。如果你已经用过pdfplumber或PyPDF2,但算出来的TF-IDF结果总是不理想,关键词里混着一堆“的”“了”“我们”,那这篇里的分词和停用词处理部分能帮你省不少调试时间。整篇内容我会按真实项目推进的顺序来讲,从选型、踩坑、参数计算到结果验证,尽量把每个“为什么这么选”说清楚。

2. 整体方案设计与工具选型:为什么是这套组合

2.1 核心思路拆解:把PDF当成一条数据流水线

很多人一上来就纠结“用哪个库读PDF最好”,但更该先想清楚整条流水线的形状。我的做法是把项目拆成三个独立阶段,每个阶段只负责一件事:

  • 抽取阶段:只负责把PDF里的文字原样拿出来,不做任何清洗和判断,输出纯文本。
  • 加工阶段:负责分词、去停用词、归一化,把文本变成词列表。
  • 统计阶段:负责构建词频矩阵、计算TF-IDF、排序输出。

这样拆的好处是,任何一段出问题都能单独定位。比如关键词里混进了页眉页脚,那问题一定在抽取阶段;如果关键词全是“系统”“功能”这种通用词,那问题在加工阶段的停用词表。我见过不少人把三步揉在一个函数里,结果调参时改一处崩三处,非常痛苦。

2.2 工具选型对比:pdfplumber、PyPDF2、pdfminer到底怎么选

PDF解析库的选择直接决定抽取质量。我实测过几个主流方案,整理成下面这张表,方便你按自己的文档类型对号入座。

工具文本抽取质量表格支持中文支持安装依赖适用场景
pdfplumber高,保留布局信息强,可提取表格好依赖pdfminer.six复杂排版、含表格的文档
PyPDF2中,纯文本为主弱一般轻量结构简单的纯文本PDF
pdfminer.six高,底层控制细中好稍重需要精细控制抽取逻辑
pymupdf很高,速度快中好需编译大批量、追求速度

我最终选的是pdfplumber,原因有三点。第一,它对中文PDF的字符编码处理比较稳,不会出现乱码方块;第二,它能拿到每个字符的坐标,后续如果想过滤页眉页脚,可以根据y坐标做裁剪;第三,它的表格抽取能力在同类里属于第一梯队,万一后续要扩展成“PDF表格转Excel”,不用换库。PyPDF2虽然轻,但遇到扫描版或复杂排版的PDF经常抽出一堆空行,调试成本反而更高。

提示:如果你的PDF是扫描件(图片型),上面这些库都抽不出文字,需要先做OCR。判断方法很简单,用pdfplumber打开后看page.chars是否为空,为空基本就是扫描件。

2.3 分词与TF-IDF方案:为什么不用sklearn一把梭

TF-IDF的计算,scikit-learn的TfidfVectorizer确实能一行搞定,但我这个项目里没有直接用它,而是选择了jieba分词 + 手写TF-IDF的组合。原因在于中文分词的粒度控制。sklearn的向量化默认按空格切词,对中文完全不适用,必须先分词再喂进去。而jieba支持自定义词典和停用词表,能针对不同领域的PDF做定制。

TF-IDF的公式本身不复杂,但理解它才能调好参数:

  • TF(词频):某个词在单篇文档中出现的次数。常见变体是除以文档总词数做归一化,避免长文档天然占优。
  • IDF(逆文档频率):log(总文档数 / 包含该词的文档数)。一个词在越多文档里出现,说明它越通用,IDF越低。
  • TF-IDF:两者相乘。既要在本篇里出现得多,又要在其他篇里出现得少,才能拿高分。

我手写实现而不是调库,主要是为了能灵活调整IDF的平滑项和TF的归一化方式。比如当文档总数很少时,IDF容易出现除零,需要加平滑;当某篇文档特别长时,纯词频会让它霸榜,需要做长度归一化。这些细节在库函数里是黑盒,自己写一遍反而心里有数。

3. 核心细节解析与实操要点:每一步的坑在哪

3.1 PDF文本抽取:页眉页脚是关键词污染的头号元凶

用pdfplumber抽取文本,最基础的写法是遍历每一页调extract_text()。但直接这么干,你会发现每页的页眉(比如公司名、文档标题)和页脚(页码、保密声明)会被重复抽进来。这些词在每页都出现,IDF极低,按理说不会成为关键词,但如果页眉里恰好有某个业务词,它就会污染结果。

我的处理办法是利用pdfplumber的坐标信息做裁剪。每一页的字符都有top和bottom坐标,页眉通常在页面顶部固定高度内,页脚在底部固定高度内。可以先抽一页看看页眉页脚的坐标范围,然后在抽取时过滤掉这些区域的字符。

import pdfplumber def extract_text_without_header_footer(pdf_path, header_height=50, footer_height=50): full_text = [] with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: height = page.height # 只保留中间区域的字符 chars = [c for c in page.chars if header_height < c['top'] < height - footer_height] # 按行重组文本 text = ''.join(c['text'] for c in chars) full_text.append(text) return '\n'.join(full_text)

这段代码的关键是header_height和footer_height两个参数,需要根据实际PDF调整。我一般先抽一页打印出所有字符的top值,观察页眉页脚的分布,再定阈值。如果PDF排版不统一,可以按比例设置,比如页眉占页面高度的8%。

注意:有些PDF的页眉页脚是作为独立对象嵌入的,坐标可能和正文重叠,这种情况坐标过滤会误伤正文。稳妥做法是先抽全文,再用正则匹配“第X页”“Page X of Y”这类模式做二次清洗。

3.2 中文分词:自定义词典决定关键词质量的上限

jieba的默认词典覆盖了通用词汇,但每个行业都有自己的专有名词。比如做医疗PDF,默认词典可能把“心肌梗死”切成“心肌”和“梗死”;做法律PDF,“不可抗力”可能被切碎。这些被切碎的词,TF-IDF算出来权重再高也没意义,因为语义已经丢了。

解决办法是加载自定义词典。我通常从两个来源构建:一是项目相关的术语表,二是先用jieba跑一遍,把高频的、被切错的词人工整理出来加进去。

import jieba # 加载自定义词典,每行一个词,可带词频和词性 jieba.load_userdict('custom_dict.txt') # 加载停用词表 def load_stopwords(path): with open(path, 'r', encoding='utf-8') as f: return set(line.strip() for line in f) stopwords = load_stopwords('stopwords.txt') def segment(text): words = jieba.lcut(text) # 过滤停用词、单字、纯数字和标点 return [w for w in words if w not in stopwords and len(w) > 1 and not w.isdigit()]

这里有几个细节值得说。第一,len(w) > 1过滤单字,因为中文单字大多没有独立语义,像“的”“了”“和”都是单字,过滤掉能大幅降噪。第二,not w.isdigit()过滤纯数字,页码、年份这类数字对关键词没贡献。第三,停用词表要持续迭代,我一般会先跑一遍,把结果里明显不该出现的词追加进去,跑个三四轮就收敛了。

3.3 TF-IDF参数计算:平滑项和归一化的取舍

手写TF-IDF时,有两个参数需要仔细定:IDF的平滑项和TF的归一化方式。

IDF的标准公式是log(N / df),其中N是文档总数,df是包含该词的文档数。当某个词在所有文档都出现时,df等于N,log(1)=0,这个词的IDF就是0,直接出局。但更常见的问题是df可能为0(理论上不会,但代码里要防),以及N很小时IDF波动大。所以我加了平滑:

import math def compute_idf(docs_words): N = len(docs_words) # 统计每个词出现在多少篇文档中 df = {} for words in docs_words: for w in set(words): df[w] = df.get(w, 0) + 1 # 加平滑项,避免除零和极端值 idf = {w: math.log((N + 1) / (df[w] + 1)) + 1 for w in df} return idf

这里的+1平滑是常见做法,保证IDF永远为正,且当df接近N时不会直接归零。TF部分我用的是词频除以文档总词数:

def compute_tf(words): tf = {} total = len(words) for w in words: tf[w] = tf.get(w, 0) + 1 return {w: c / total for w, c in tf.items()}

归一化的好处是消除文档长度差异。一篇一万字的文档和一篇一千字的文档,同一个词出现10次,前者TF是0.001,后者是0.01,后者更能代表这篇文档的主题。如果不归一化,长文档会系统性地霸占关键词榜。

3.4 结果排序与输出:怎么判断关键词“好不好”

算出TF-IDF后,按分值降序取Top N。但分值高不代表词好,我一般会做两轮人工校验。第一轮看Top 20里有没有明显的噪声词(停用词漏网、页眉残留),有就补进停用词表重跑。第二轮看Top 20是否覆盖了文档的核心主题,如果一份讲“供应链金融”的PDF,Top词里没有“供应链”“融资”“应收账款”这类词,说明分词或词典有问题。

输出格式我习惯用CSV,每行是“文档名, 关键词, TF-IDF分值”,方便后续用Excel做透视或筛选。如果文档量大,还会额外输出一个“全局词云数据”,把所有文档的Top词合并统计,看整个语料的主题分布。

4. 完整实操流程:从零跑通一个可复现的版本

4.1 环境准备与依赖安装

先把环境搭起来。我用的Python版本是3.9以上,依赖库不多,一条命令搞定:

pip install pdfplumber jieba

pdfplumber会自动带上pdfminer.six作为底层依赖。jieba是纯Python实现,安装很快。如果你后续想画词云,再加wordcloud和matplotlib,但核心功能不需要它们。

目录结构我建议这样组织,方便管理:

project/ ├── pdfs/ # 存放待处理的PDF ├── output/ # 输出结果 ├── custom_dict.txt # 自定义词典 ├── stopwords.txt # 停用词表 └── main.py # 主程序

停用词表可以从网上找一份通用的中文停用词表打底,然后根据你的领域往里加。我一般会加“本文”“研究”“分析”“方法”这类学术套话,以及“公司”“部门”这类在特定语料里高频但无区分度的词。

4.2 主程序编写:把四个阶段串起来

主程序按“抽取→分词→统计→输出”的顺序组织。先遍历pdfs目录,对每个PDF抽文本、分词,把所有文档的词列表收集到一个大列表里,然后统一算IDF,再逐篇算TF-IDF。

import os import csv import pdfplumber import jieba def load_stopwords(path): with open(path, 'r', encoding='utf-8') as f: return set(line.strip() for line in f) def extract_text(pdf_path): text = [] with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: t = page.extract_text() if t: text.append(t) return '\n'.join(text) def segment(text, stopwords): words = jieba.lcut(text) return [w for w in words if w not in stopwords and len(w) > 1 and not w.isdigit()] def main(): jieba.load_userdict('custom_dict.txt') stopwords = load_stopwords('stopwords.txt') pdf_dir = 'pdfs' docs = [] # 每篇文档的词列表 names = [] for fname in os.listdir(pdf_dir): if not fname.endswith('.pdf'): continue path = os.path.join(pdf_dir, fname) text = extract_text(path) words = segment(text, stopwords) docs.append(words) names.append(fname) print(f'{fname}: 抽取{len(words)}个有效词') # 计算IDF N = len(docs) df = {} for words in docs: for w in set(words): df[w] = df.get(w, 0) + 1 idf = {w: math.log((N + 1) / (df[w] + 1)) + 1 for w in df} # 逐篇计算TF-IDF并输出 os.makedirs('output', exist_ok=True) with open('output/keywords.csv', 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['文档', '关键词', 'TF-IDF分值']) for name, words in zip(names, docs): tf = {} for w in words: tf[w] = tf.get(w, 0) + 1 total = len(words) scores = {w: (c / total) * idf.get(w, 0) for w, c in tf.items()} top = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:15] for w, s in top: writer.writerow([name, w, round(s, 6)]) print('处理完成,结果已写入 output/keywords.csv') if __name__ == '__main__': main()

这段代码可以直接跑,前提是pdfs目录里有PDF文件,custom_dict.txt和stopwords.txt存在。输出用utf-8-sig编码,是为了Excel打开CSV不乱码,这个细节很多人会忽略,导致中文显示成乱码。

4.3 参数调优实录:一次真实的关键词优化过程

我拿一批技术文档跑第一遍时,Top关键词里出现了“系统”“功能”“支持”“使用”这类词。这些词在每篇文档都出现,IDF很低,按理说不该上榜,但它们TF太高,硬是把分值拉起来了。这说明停用词表不够,需要把这些通用词加进去。

第二遍跑完,“系统”“功能”没了,但出现了“本文”“介绍”“如下”。这是典型的文档结构词,继续加停用词。第三遍,关键词开始有业务含义了,但发现“数据”这个词在好几篇里都排第一,区分度不够。查了一下,这批文档确实都涉及数据,但每篇的侧重点不同。这时候单纯靠停用词解决不了,需要调整IDF的平滑策略,或者引入“词性过滤”,只保留名词和动词。

我最终的做法是加了一层词性过滤,用jieba的posseg模块,只保留名词(n开头)、动词(v开头)和英文(eng)。这样“数据”如果被标为名词还是会保留,但“如下”“介绍”这类会被过滤掉。词性过滤的代价是速度慢一些,但关键词质量提升明显。

优化轮次主要问题处理手段效果
第一轮通用词霸榜补充停用词通用词减少
第二轮结构词残留继续补停用词结构词清除
第三轮区分度不足词性过滤关键词更聚焦
第四轮专有名词被切碎自定义词典语义完整

4.4 结果验证:怎么确认算出来的关键词是对的

验证分两步。第一步是抽样人工核对,随机挑三篇文档,看Top 10关键词是否和文档主题吻合。如果一篇讲“容器编排”的文档,Top词里有“容器”“集群”“调度”“镜像”,那基本没问题;如果出现“我们”“可以”“进行”,说明清洗不到位。

第二步是跨文档对比,看同一个词在不同文档里的分值差异。如果某个词在所有文档里分值都差不多,说明它区分度低,可以考虑从结果里剔除。我一般会算一个“分值方差”,方差小的词直接过滤掉,只保留那些在某些文档里特别突出的词。

提示:TF-IDF本质是统计方法,它不知道词义。同义词(如“电脑”和“计算机”)会被当成两个词分别计算,导致分值分散。如果语料里同义词多,可以考虑做同义词合并,或者用词向量做语义聚类,但那是另一个量级的工程了。

5. 常见问题与排查技巧实录

5.1 抽取阶段的高频问题

问题一:抽出来的文本全是乱码或方块。这通常是PDF的字体编码问题,pdfplumber对某些嵌入字体的支持不完善。排查方法是换pymupdf试一下,如果pymupdf能正常抽,说明是库的兼容性问题。解决手段有限,可以尝试指定laparams参数调整pdfminer的解析策略,或者对PDF做一次“打印为PDF”的预处理,重新生成标准编码的副本。

问题二:文本顺序错乱,段落被拆得七零八落。多栏排版的PDF容易出现这个问题,pdfplumber按坐标顺序抽字符,多栏时可能左栏右栏交错。解决办法是用page.extract_text(layout=True)保留布局,或者按x坐标把页面分成左右两栏分别抽取。我遇到多栏文档时,一般先抽一页看字符的x坐标分布,找到栏与栏之间的空隙坐标,然后按这个坐标切分。

问题三:表格内容混进正文,变成一堆无意义的数字。pdfplumber的extract_text()会把表格里的文字也抽出来,但顺序可能错乱。如果表格内容对关键词分析是噪声,可以在抽取前用page.find_tables()定位表格区域,把这些区域的字符排除掉。

5.2 分词与统计阶段的典型故障

故障一:关键词里出现大量单字。检查len(w) > 1的过滤是否生效。如果生效了还有单字,可能是jieba把某个词切成了单字,比如“的”被单独切出来。这时候要看自定义词典是否覆盖了这些词,或者停用词表是否遗漏。

故障二:某篇文档的关键词全是另一篇文档的词。这通常是因为两篇文档内容高度相似,IDF区分不开。解决办法是引入文档聚类的预处理,把相似文档合并或标记,避免它们互相干扰。或者改用TF-ISF等变体,但那是更复杂的方案。

故障三:程序跑得特别慢。瓶颈通常在PDF抽取,尤其是页数多、排版复杂的文档。优化手段有三个:一是用多进程并行处理不同PDF;二是先用pymupdf快速抽一遍,只对抽不出文字的PDF用pdfplumber精细处理;三是缓存抽取结果,避免重复跑。

5.3 常见问题速查表

现象可能原因排查方法解决手段
文本乱码字体编码问题换pymupdf对比预处理PDF或换库
段落错乱多栏排版查看字符x坐标按栏切分抽取
表格混入未排除表格区用find_tables定位排除表格区域字符
单字残留过滤未生效打印分词结果检查过滤条件
关键词雷同文档相似度高算文档相似度聚类或合并处理
运行缓慢PDF抽取耗时计时各阶段并行或缓存

5.4 几条踩坑换来的经验

第一,不要等到最后才验证抽取质量。我一开始图快,直接跑完整流程,结果关键词一塌糊涂,回头查发现是抽取阶段就把页眉页脚混进去了。后来改成每抽一篇就打印前200字看看,问题当场就能发现。

第二,停用词表要按语料定制。通用停用词表只能解决“的”“了”这类,但每个领域的套话不同。技术文档有“系统”“功能”,法律文档有“甲方”“乙方”,学术文档有“研究”“分析”。我现在的做法是每接一批新语料,先跑一遍,把Top 50里的非业务词全部加进停用词表,再跑第二遍。

第三,TF-IDF不是万能的。它擅长找“区分词”,不擅长找“主题词”。如果一份文档的核心主题词在其他文档也频繁出现,它的TF-IDF分值会被压低。这种情况下,可以结合词频(TF)一起看,或者用TextRank等基于图的方法做补充。我一般会把TF-IDF和纯词频的结果都输出,交叉对比。

第四,中文分词的效果高度依赖词典。jieba的默认词典对通用文本够用,但专业领域一定要加自定义词典。我做过一个对比实验,同一批医疗PDF,不加自定义词典时Top关键词里有“患者”“治疗”这类通用词,加了“心肌梗死”“冠状动脉”等专有词后,关键词的专业度明显提升。

6. 扩展方向:这套流程还能怎么用

跑通基础版本后,这套流程可以往几个方向扩展。第一个方向是批量文档的主题聚类,把每篇文档的TF-IDF向量作为特征,用KMeans或层次聚类分组,自动把相似文档归到一起。第二个方向是关键词随时间的变化分析,如果PDF有日期信息,可以按时间切片,看哪些词在某个时间段突然变热。第三个方向是结合词向量做语义搜索,把关键词映射到向量空间,支持“找和这份文档主题相似的其他文档”这类需求。

我自己最常用的扩展是输出词云数据。把每篇文档的Top词和分值导出后,用wordcloud库画一张图,分值越高的词字号越大。这张图放在报告开头,比一堆表格直观得多。画词云时注意中文字体要指定,否则会显示成方块,font_path参数指向系统里的中文字体文件即可。

另外,如果PDF数量很大(上千份),单机跑会吃力,可以考虑把抽取和分词阶段拆成独立任务,用消息队列分发,多台机器并行处理。但这是工程化的范畴了,对于几十到几百份文档的日常需求,单机脚本完全够用。我处理过最大的一批是三百多份PDF,单机跑完抽取加统计大概十几分钟,瓶颈在PDF抽取,分词和统计反而很快。

RELATED READING

延伸阅读

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