ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

中文关键词抽取:TF-IDF、TextRank与KeyBERT三种Python方案选型与实战

中文关键词抽取:TF-IDF、TextRank与KeyBERT三种Python方案选型与实战

简介:这份资源面向Python初学者、NLP入门者及需要完成课程设计的学生,系统讲解中文文本关键词抽取的三种经典方法:TF-IDF、TextRank与Word2Vec词向量聚类。内容从原理、流程到代码实现逐层展开,并配有课程论文与实验截图,帮助读者理解各方法的适用场景与差异。压缩包共31个文件,约1.78MB,包含4个py源码脚本、14个csv数据与结果文件、8张png实验截图、2个txt词表与停用词文件,以及docx论文、md说明和license,结构完整,便于直接运行与复现。资源还讨论了若干可改进细节,如引入专业语料训练Word2Vec、为标题词语赋予初始权重、按分类数设置KMeans的n_clusters参数、过滤高频通用词等,为后续优化提供思路。目前已有2116人学习下载,适合作为课程设计参考或关键词抽取入门实践素材。

1. 中文关键词抽取:三种Python方案,到底该选哪一个

做中文文本处理的人迟早会撞上同一个需求:给你一篇几千字的文章、一批用户评论或者一份行业报告,让你把里面最核心的几个词拎出来。这件事听起来简单,真动手才发现坑不少——中文没有天然空格分词,同一个意思可以用完全不同的词表达,而且"重要"本身就没有统一定义。基于Python实现中文文本关键词抽取,市面上能落地的方案主要就三条路:统计法(TF-IDF)、图模型法(TextRank)、以及预训练语义法(KeyBERT这类)。这三种方法背后是三种完全不同的"重要性"哲学,选错了不是效果差一点,而是方向性错误。

这篇文章面向的是需要把关键词抽取真正跑进业务里的工程师——做舆情监控的、做内容标签的、做搜索召回的、做知识库自动归档的。我会把三种方法的原理边界、可复现的代码、参数怎么调、以及我踩过的坑一次讲清楚。你跟着走能跑通最小demo,也能看到每种方法在什么数据上会翻车。免费python源码大全里这类脚本一抓一大把,但大多数只给你一个能跑的壳子,参数为什么这么设、换一批数据还灵不灵,基本没人讲。这篇补的就是这一段。

2. 三种方法的原理边界与选型判断

2.1 TF-IDF:统计词频和逆文档频率的加权逻辑

TF-IDF的核心思想非常朴素:一个词在当前文档里出现得越多(TF高),同时在所有文档里出现得越少(IDF高),它就越可能是这篇文档的关键词。TF通常是词频归一化,IDF是总文档数除以包含该词的文档数再取对数。两者相乘得到权重,排序取TopN。

这个逻辑成立的前提是:你有"一批文档"而不是"一篇文档"。IDF必须靠语料库统计出来,单篇文档算不了IDF。所以TF-IDF天然适合"从一批文章里各抽各的关键词"这种场景,比如给1000篇新闻各打5个标签。如果你只有一篇文章,TF-IDF退化成纯词频排序,效果会明显下降——高频的"的""是""我们"这类词会霸榜,除非你有一份足够好的停用词表。

另一个边界是:TF-IDF完全不理解语义。"苹果手机"和"iPhone"在它眼里是两个无关的词,"降价"和"促销"也毫无关联。同义词、近义词、缩写,它一概不认。所以TF-IDF适合做粗筛和基线,不适合做需要语义理解的精细抽取。

2.2 TextRank:把词当节点,用共现窗口建图

TextRank借用了PageRank的思路:把每个词当成图里的一个节点,如果两个词在同一个滑动窗口(通常窗口大小5到10)内共现,就在它们之间连一条边。然后迭代计算每个节点的重要性——一个词如果和很多重要词共现,它自己也重要。

相比TF-IDF,TextRank有两个明显优势。第一,它不需要语料库,单篇文档就能跑,因为它靠的是文档内部的词共现结构。第二,它考虑了词与词之间的关系,能捕捉到一些TF-IDF漏掉的词——比如某个词词频不高,但它总是和核心词一起出现,TextRank会给它加分。

但TextRank也有软肋。它对分词质量极其敏感,分词错了,共现窗口里全是噪声,图就建歪了。窗口大小这个参数也很玄学:太小(比如2)图太稀疏,很多词孤立;太大(比如20)图太稠密,区分度下降。我一般从5开始试,根据文档平均长度微调。另外TextRank在短文本上表现不稳定,因为短文本共现关系本来就少,图容易退化成几个孤岛。

2.3 预训练语义法:用句向量相似度找关键词

前两种方法本质上都是"词袋"模型,丢掉了词序和语义。预训练语义法的思路完全不同:先把候选词和整篇文档都编码成向量,然后算候选词向量和文档向量的余弦相似度,相似度高的就是关键词。KeyBERT就是这套逻辑的典型实现——用BERT类模型做编码器,候选词通过n-gram切分或分词得到。

这种方法最大的好处是能理解语义。"降价"和"促销"在向量空间里距离很近,即使它们从不在同一篇文档里共现,语义法也能把它们关联起来。对于同义词多、表达灵活的领域(比如用户评论、社交媒体文本),语义法通常比前两种高一个档次。

代价是计算资源。BERT类模型推理需要GPU或者至少是性能不错的CPU,处理大批量文档时速度远不如TF-IDF。而且模型选择很关键——通用预训练模型在垂直领域(医疗、法律、金融)上可能表现平平,需要领域微调或者换用领域模型。另外候选词的切分策略直接影响结果,n-gram的n设多少、要不要过滤停用词,都是需要调的。

2.4 三种方法的选型对照表

维度TF-IDFTextRank预训练语义法
是否需要语料库需要(算IDF)不需要不需要
单篇文档可用性差好好
语义理解能力无弱(靠共现)强
计算速度极快快慢(依赖模型)
对分词质量敏感度中高低(可用n-gram)
短文本表现中差好
典型适用场景批量文档打标签单篇长文抽取语义精细抽取

选型建议很直接:如果你有大批量文档、追求速度和基线效果,TF-IDF起步;如果你处理的是单篇长文、想要比TF-IDF好一点又不想上模型,TextRank;如果语义准确性是硬指标、资源不是瓶颈,上预训练语义法。实际项目里我经常把TF-IDF和TextRank的结果做交集或加权融合,比单用一种稳。

3. 用jieba把三种方法跑通的最小代码

3.1 环境准备与依赖安装

先把环境搭好。Python 3.8及以上都行,核心依赖就几个:jieba做分词,scikit-learn提供TF-IDF实现,jieba.analyse自带TextRank,KeyBERT需要单独装。如果你用vscode python环境配置,记得选对解释器,不然pip装到别的环境里去了。

pip install jieba scikit-learn keybert sentence-transformers

jieba是中文分词的标配,安装后自带一份词典,开箱能用。scikit-learn的TfidfVectorizer封装好了TF-IDF计算,不用自己手写。KeyBERT底层依赖sentence-transformers,第一次运行会自动下载预训练模型,需要网络能访问模型仓库。如果下载慢,可以提前把模型缓存到本地。

提示:KeyBERT默认用的模型是paraphrase-MiniLM-L6-v2,英文为主。中文场景建议换成shibing624/text2vec-base-chinese或者BAAI/bge-small-zh-v1.5,效果差别很大。

3.2 TF-IDF抽取:TfidfVectorizer参数怎么设

import jieba from sklearn.feature_extraction.text import TfidfVectorizer # 假设有一批文档 docs = [ "苹果公司发布了新款手机,搭载最新芯片,拍照性能大幅提升", "国产新能源汽车销量持续增长,电池技术成为竞争焦点", "苹果手机降价促销,消费者购买意愿明显增强", "新能源汽车补贴政策调整,行业面临重新洗牌", ] # 用jieba分词,空格连接 def tokenize(text): return " ".join(jieba.cut(text)) corpus = [tokenize(d) for d in docs] # 关键参数设置 vectorizer = TfidfVectorizer( token_pattern=r"(?u)\b\w+\b", # 匹配中文词 max_df=0.8, # 忽略出现在80%以上文档中的词 min_df=1, # 至少出现在1篇文档中 max_features=100 # 最多保留100个特征词 ) tfidf_matrix = vectorizer.fit_transform(corpus) feature_names = vectorizer.get_feature_names_out() # 取第一篇文档的Top5关键词 import numpy as np doc_idx = 0 scores = tfidf_matrix[doc_idx].toarray()[0] top_indices = np.argsort(scores)[::-1][:5] for i in top_indices: if scores[i] > 0: print(feature_names[i], round(scores[i], 4))

这段代码的逻辑是:先用jieba把每篇文档切成空格分隔的词序列,然后TfidfVectorizer统计词频和逆文档频率。max_df=0.8的作用是过滤掉太常见的词——如果某个词在80%以上的文档里都出现,它区分度太低,不该当关键词。min_df=1表示至少出现一次就保留,语料少的时候设1,语料大可以设2或3过滤低频噪声。max_features控制最终词表大小,太大计算慢,太小可能漏掉关键词。

注意token_pattern这个参数。scikit-learn默认的词模式是匹配两个及以上字母数字字符,对中文不友好,必须改成r"(?u)\b\w+\b"才能正确匹配中文词。这是很多人第一次用TF-IDF处理中文时翻车的地方——跑出来全是空结果,查半天才发现是token_pattern的问题。

3.3 TextRank抽取:jieba.analyse的窗口与TopN

import jieba.analyse text = "苹果公司发布了新款手机,搭载最新芯片,拍照性能大幅提升。苹果手机降价促销,消费者购买意愿明显增强。" # 提取Top10关键词,带权重 keywords = jieba.analyse.textrank( text, topK=10, withWeight=True, allowPOS=('ns', 'n', 'vn', 'v') # 只保留地名、名词、动名词、动词 ) for word, weight in keywords: print(word, round(weight, 4))

jieba的textrank接口很简洁,但有几个参数必须理解。allowPOS控制哪些词性的词能进入候选集,默认是('ns', 'n', 'vn', 'v'),也就是地名、名词、动名词、动词。这个设置能过滤掉大部分虚词,但也会漏掉一些有意义的形容词或副词。如果你的领域里形容词是关键(比如情感分析),需要把'a'加进去。

窗口大小jieba没有直接暴露参数,它内部用的是固定窗口(默认5)。如果你需要调整窗口,得自己实现TextRank或者改jieba源码。我一般不建议为了调窗口去改源码,而是通过调整allowPOS和分词词典来间接影响图结构。

withWeight=True返回权重值,方便你做阈值过滤。实际用的时候我会设一个权重下限,比如0.1以下的直接丢掉,避免输出一堆低质量词。

3.4 KeyBERT语义抽取:中文模型替换与n-gram设置

from keybert import KeyBERT # 替换为中文预训练模型 kw_model = KeyBERT(model="shibing624/text2vec-base-chinese") doc = "苹果公司发布了新款手机,搭载最新芯片,拍照性能大幅提升。苹果手机降价促销,消费者购买意愿明显增强。" keywords = kw_model.extract_keywords( doc, keyphrase_ngram_range=(1, 2), # 候选词长度1到2 stop_words=None, # 中文停用词需自行传入 top_n=8, use_mmr=True, # 用MMR做多样性去重 diversity=0.5 # 多样性强度 ) for kw, score in keywords: print(kw, round(score, 4))

KeyBERT的核心参数是keyphrase_ngram_range,它决定候选词的长度范围。(1,2)表示候选词可以是单个词或两个词的组合。中文里很多关键概念是双词组合,比如"新能源汽车""拍照性能",所以设(1,2)或(1,3)比较合理。设太大(比如(1,5))会产生很多无意义的长短语。

use_mmr=True开启最大边际相关性去重,避免抽出来的关键词全是同义词。diversity参数控制去重强度,0到1之间,越大结果越多样。我一般设0.5到0.7,太低会重复,太高会引入不相关的词。

中文停用词KeyBERT不会自动处理,需要你自己传一个停用词列表。常见做法是加载一份中文停用词表,在extract_keywords之前先对文档做停用词过滤,或者通过stop_words参数传入。

注意:KeyBERT第一次运行会下载模型,如果网络不通会卡住。可以提前用sentence-transformers把模型下载到本地缓存目录,再指定本地路径加载。

4. 参数调优与效果验证的实操细节

4.1 停用词表:三种方法共用的地基

停用词表是中文关键词抽取里最容易被忽视、又最影响结果的一环。不管用哪种方法,如果停用词没处理好,"的""了""是""在""我们""这个"这类词会大量占据候选位置。TF-IDF里它们IDF低但TF高,TextRank里它们和所有词都共现导致权重虚高,语义法里它们和文档向量相似度也不低。

我一般准备三份停用词表叠加使用:通用停用词表(网上能找到的标准表,大概1200词左右)、领域停用词表(比如做医疗文本时把"患者""治疗"这类太泛的词加进去)、以及项目特有的高频无意义词(跑一遍全量数据,把Top50高频但无区分度的词手动加进去)。三份合并去重后加载。

def load_stopwords(*paths): stopwords = set() for path in paths: with open(path, encoding="utf-8") as f: for line in f: w = line.strip() if w: stopwords.add(w) return stopwords stopwords = load_stopwords("stopwords_general.txt", "stopwords_domain.txt")

加载后在分词阶段就过滤掉,而不是在抽取后过滤。因为停用词进入图结构或向量计算会污染整个结果,事后过滤只能去掉输出里的停用词,去不掉它们对权重的干扰。

4.2 分词质量对TextRank的放大效应

TextRank对分词质量的敏感度是三种方法里最高的。原因在于它依赖词共现建图,如果分词把"新能源汽车"切成了"新能源"和"汽车",这两个词各自和不同的词共现,图结构就变了,最终权重排序也会变。更糟的是,如果切错了,比如把"性能提升"切成"性能"和"提升",而"提升"又和"提高""增加"等词共现,图里会出现一堆语义相近但分散的节点,稀释了真正关键词的权重。

解决办法有两个。第一,加载自定义词典,把领域专有名词加进去,确保它们不被切碎。jieba支持load_userdict,词典格式是"词 词频 词性"。

jieba.load_userdict("user_dict.txt") # user_dict.txt内容示例: # 新能源汽车 100 n # 拍照性能 50 n # 芯片制程 30 n

第二,对分词结果做后处理,把被切碎的词合并回去。比如维护一个"必须合并"的词对列表,扫描分词结果,遇到就合并。这个方法比较笨但有效,适合领域词汇量不大的场景。

4.3 用人工标注集验证抽取效果

三种方法跑出来的结果怎么判断好坏?不能靠感觉。我一般会标一个小的验证集:选20到50篇文档,每篇人工标3到5个真正的关键词,然后算抽取结果和标注的命中率(Recall)和准确率(Precision)。

def evaluate(extracted, labeled, topk=5): extracted_set = set([w for w, _ in extracted[:topk]]) labeled_set = set(labeled) hit = extracted_set & labeled_set precision = len(hit) / len(extracted_set) if extracted_set else 0 recall = len(hit) / len(labeled_set) if labeled_set else 0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0 return precision, recall, f1

这个评估很粗糙,因为关键词没有唯一正确答案,人工标注也有主观性。但它能给你一个相对比较的基准——同一批数据上,TF-IDF的F1是0.4,TextRank是0.5,KeyBERT是0.6,那你就知道语义法在这个场景确实更好。如果三种方法F1都在0.3以下,那大概率是分词或停用词出了问题,先回去查那两块。

验证集不用大,20篇就够看出趋势。关键是标注要一致——同一个人标,标准统一。我见过有人标的时候把"苹果"标了,另一个人觉得"苹果"太泛没标,结果评估出来一堆假阴性,白折腾。

5. 避坑与排查:关键词抽取最常见的五个翻车现场

5.1 现象:TF-IDF跑出来全是单字,没有完整词

原因基本只有一个:分词没做,或者做了但没把词用空格连接。TfidfVectorizer默认按空格和标点切分,如果你直接把中文原文喂进去,它会按字符切,每个汉字变成一个token。另一个可能是token_pattern没改,默认模式匹配不到中文词。

解决:确认分词步骤存在,且分词结果用空格join后再传给vectorizer。同时检查token_pattern是否设为r"(?u)\b\w+\b"。这两步做完,输出应该就是完整词了。

5.2 现象:TextRank结果里全是"表示""认为""进行"这类动词

原因是allowPOS设置太宽,或者停用词表没覆盖这类高频动词。jieba默认的allowPOS包含'v',而"表示""认为""进行"都是动词,词频高、共现广,TextRank权重自然高。

解决:收紧allowPOS,去掉'v',只保留'n'和'vn'。或者在停用词表里把这类泛动词加进去。更好的做法是两者结合——allowPOS保留'n'和'vn',停用词表补充领域泛动词。

5.3 现象:KeyBERT第一次运行卡住不动

这是模型下载的问题。KeyBERT底层用sentence-transformers,第一次运行会从模型仓库下载预训练权重。如果网络环境访问模型仓库慢或者不通,就会卡在下载步骤,没有任何报错,看起来像死机。

解决:提前手动下载模型。可以用sentence-transformers的SentenceTransformer类指定本地路径加载,或者设置环境变量把缓存目录指向一个已经下载好的位置。如果团队里有其他人已经下载过,直接拷贝缓存目录最省事。

5.4 现象:三种方法抽出的关键词高度重叠,没有互补效果

这通常说明你的数据太"规范"了——文档长度适中、用词标准、没有太多同义词变体。在这种情况下,三种方法的差异确实不大,因为TF-IDF和TextRank在规范文本上表现接近,语义法的优势也体现不出来。

解决:如果业务需要差异化结果,可以调整各方法的参数让它们侧重不同。比如TF-IDF调大max_df保留更多常见词,TextRank调小窗口让图更稀疏,KeyBERT调大diversity让结果更多样。然后做加权融合,而不是简单取交集。如果数据本身就很规范,那用TF-IDF就够了,不必强上语义法。

5.5 现象:换一批数据后效果断崖式下降

最常见的原因是停用词表和自定义词典没有跟着更新。第一版停用词表是基于第一批数据做的,换了一批数据后,新的高频无意义词没被过滤,新的领域术语没被加入词典,分词切错、停用词漏网,效果自然崩。

解决:每次换数据源,先跑一遍全量数据,统计Top100高频词,人工过一遍,把该加停用词的加进去,该加自定义词典的加进去。这个步骤花不了半小时,但能避免后面大量的调试时间。我一般把这个流程固化成脚本,换数据时自动跑一遍高频词统计,输出候选列表供人工确认。

6. 把三种方法融合成一个可复用的抽取管道

单用一种方法总有短板,实际项目里我更倾向于把三种方法串成一个管道:TF-IDF做粗筛,TextRank做补充,KeyBERT做精排。具体做法是先用TF-IDF和TextRank各取Top20,合并去重得到候选集,然后用KeyBERT对候选集做语义打分,按语义分排序取最终TopN。这样既保留了统计方法的召回广度,又用语义方法提升了排序精度。

def hybrid_extract(text, topk=10): # 第一步:TF-IDF粗筛 tfidf_kws = tfidf_extract(text, topk=20) # 第二步:TextRank补充 tr_kws = textrank_extract(text, topk=20) # 合并候选集 candidates = list(set([w for w, _ in tfidf_kws] + [w for w, _ in tr_kws])) # 第三步:KeyBERT语义精排 candidate_text = " ".join(candidates) semantic_kws = kw_model.extract_keywords( text, candidates=candidates, top_n=topk ) return semantic_kws

这个管道的参数需要根据数据调。候选集大小20是个经验值,文档长可以设30,文档短设10。KeyBERT的candidates参数直接传入候选词列表,避免它自己切n-gram产生噪声。最终TopN根据业务需求定,做标签一般5到8个,做搜索召回可以10到15个。

验证管道效果的方法和单方法一样,用人工标注集算F1。我实测下来,融合管道在多数中文数据集上比单用TF-IDF提升15%到25%的F1,比单用KeyBERT提升5%到10%,代价是计算时间增加——KeyBERT那一步是瓶颈。如果对延迟敏感,可以只对TF-IDF和TextRank的交集做语义精排,候选集小一半,速度快不少。

最后一个习惯:每次上线新数据前,先抽100篇跑一遍,人工看Top10结果,确认没有明显翻车再全量跑。这个"后悔药"花10分钟,能省掉后面几小时的排查。希望帮到你。

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

RELATED READING

延伸阅读

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