简介:面向Python自然语言处理入门者与课程设计场景,这份资源包系统讲解中文文本关键词抽取的三种主流实现:TF-IDF、TextRank与Word2Vec词向量聚类,内容涵盖原理分析、流程梳理、代码实现与实验对比,并针对每种方法给出可运行的Python脚本和详细注释。包内共31个文件,以4个Python脚本、14个CSV数据结果、8张项目截图、1份课程论文Word及说明文档为主,整体压缩后仅1.78MB,目录按方法划分,便于分模块学习。已有2114人学习下载,适合作为课程设计参考或算法实践素材。资源不仅提供基于TF-IDF、TextRank及两种Word2Vec变体的完整抽取代码,还包含样本数据、词性标注参考、词向量中间结果及多组对比CSV,可直观对比各方法在关键词提取上的效果差异;课程论文中另对可优化方向(如加入专业语料、标题文本加权、按分类数调整聚类参数等)作了阐述,方便读者在现有项目上继续改进与扩展。
1. 中文关键词抽取:为什么三种方法都要会
中文文本的关键词抽取是信息检索、文本摘要、舆情分析和知识图谱构建的基础环节。与英文不同,中文没有天然空格分隔,分词质量直接决定关键词抽取的上限。很多初学者以为调一个jieba.analyse就万事大吉,但线上场景里,TF-IDF对专有名词不敏感,TextRank对长文本计算开销大,而基于向量的深度方法又依赖标注数据和推理资源。三种方法各有适用边界,我见过很多生产项目因为只押注单一算法,换一个领域语料后效果断崖式下跌。
这篇文章的核心是:用Python分别实现基于统计(TF-IDF)、基于图(TextRank)和基于语义(BERT+向量相似度)的三条关键词抽取路径。它们都能处理中文文本,但对停用词、词性、文本长度和领域适配的响应完全不同。读完你不仅能看到jieba之外的实现细节,还能根据文本类型快速选型。适合对NLP有一定基础、正在做信息抽取或搜索排序的工程师,也适合需要把关键词服务封装成接口的后端开发者。
2. 方法一:基于TF-IDF的中文关键词抽取
2.1 TF-IDF为什么在中文场景里要重新思考
TF-IDF的核心假设是:一个词在当前文档中出现频率高,但在整个语料库中出现频率低,则该词具有较好的区分能力。这个假设在中文场景有两个天然断层。第一,中文分词后产生的单字噪音多,比如“的”“了”“是”在停用词表之外仍有大量干扰;第二,中文的领域专有名词往往由2到6个字组成,单纯统计词频无法分辨“机器学习”和“学习”哪个才是真正的主题词。
常见做法是引入词性过滤。名词、动词、形容词对关键词的贡献远大于副词和助词,所以工业实现会先用词性标注筛一遍候选词,再做TF-IDF加权。jieba.analyse内部的extract_tags其实已经封装了idf权重文件,但它默认的idf语料来自人民日报和小说文本,换到法律、医疗或代码文档领域时,权重分布会失真。
另一个常见坑是归一化。短文本的TF值天然偏低,直接套用标准TF-IDF公式会把短文本中的每个词都推成高权重。这时需要做词频归一化,比如除以当前文档的最大词频,或者对TF做亚线性变换($1 + \log(tf)$),后者对中英文都适用。
2.2 手写一个适合中文的TF-IDF抽取器
直接用jieba.analyse当然最快,但如果要控制字段权重和停用词策略,我习惯自己算一遍核心逻辑。下面是一个可复用的最小实现:
import jieba import jieba.posseg as pseg import math from collections import Counter class TFIDFExtractor: def __init__(self, stopwords_path=None, idf_path=None): self.stopwords = set() if stopwords_path: with open(stopwords_path, 'r', encoding='utf-8') as f: self.stopwords = {line.strip() for line in f} # idf权重:格式为“词 权重”,每行一个 self.idf = {} if idf_path: with open(idf_path, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split(' ') if len(parts) == 2: self.idf[parts[0]] = float(parts[1]) def extract(self, text, top_k=10, with_weight=True): # 词性筛选:保留名词、动词、形容词、英文单词 words = [] for word, flag in pseg.cut(text): if word.strip() and word not in self.stopwords: if flag.startswith('n') or flag.startswith('v') or flag.startswith('a'): words.append(word) freq = Counter(words) max_freq = max(freq.values()) if freq else 1 results = [] for word, tf in freq.items(): tf_norm = 1 + math.log(tf) # 亚线性TF归一化 idf = self.idf.get(word, math.log(10000)) # 默认idf给一个较大值 score = tf_norm * idf results.append((word, score)) results.sort(key=lambda x: x[1], reverse=True) return results[:top_k] if with_weight else [w for w, _ in results[:top_k]] # 使用示例 text = "Python是解释型语言,Python社区提供了丰富的文本处理库,尤其在中文分词和关键词抽取方面有大量成熟方案。" extractor = TFIDFExtractor(stopwords_path='stopwords.txt') keywords = extractor.extract(text, top_k=5) print(keywords)代码里做了三件关键事:用pseg.cut做词性过滤,把副词和助词直接排除;用1 + log(tf)做亚线性归一化,防止长文档里高频词碾压性上榜;idf表外部注入,方便切换不同领域的权重文件。
idf_path如果不传,默认给每个词分配log(10000)约等于9.21的权重,这相当于假设该词在约一万篇文档中只出现一次,是一个保守的初始值。实际使用时,建议针对你的语料库离线统计idf值,格式就是上面的“词 权重”。
2.3 TF-IDF的参数调节与明显局限
top_k的选择跟文本长度强相关。新闻类文本建议取5到10,技术文档可以取10到20,短文本对话记录取3到5就够。停用词表是TF-IDF效果的第一决定因素,不要只依赖jieba.analyse内置的停用词,至少加入你所在领域的非语义高频词。
TF-IDF最大的痛点是它无法处理语义同义和一词多义。“Python”和“蟒蛇”在同一篇爬虫技术文章里贡献的是不同特征维度,但语义指向同一个主题。下一篇要讲的TextRank在局部语义上有轻微改善,但对多义词仍然无能为力。如果业务要求关键词能聚合同义表达,必须切换到深度学习方案。
3. 方法二:基于TextRank的中文关键词抽取
3.1 TextRank的图模型与PageRank的血缘关系
TextRank的思想来源于PageRank。它把每个候选词看成一个节点,把词与词之间的共现关系看成有向边,通过迭代传播权重,最终收敛得到每个词的稳定权重。这里的“共现窗口”是一个核心参数:设定一个窗口大小(比如前后各5个词),窗口内任意两个词之间建立连接。
中文场景里,TextRank与TF-IDF的差异体现在两个层面。第一,TF-IDF是完全无状态的词频统计,TextRank捕捉了词与词之间的局部依赖关系,所以“人工智能”和“机器学习”在同一窗口频繁共现时,两者的得分会互相增强。第二,TextRank不依赖外部语料库,单篇文档就能完成计算,这让它在短文本和低资源场景下比TF-IDF更稳。
但TextRank的坑也在这里。窗口大小设置不当会导致完全不同的结果:窗口太小,共现关系稀疏,图接近离散;窗口太大,所有词都互相连接,退化成纯词频统计。中文的停用词如果不提前过滤,会把“的”“了”这类高频词变成图中的hub节点,把大量无关词连接在一起。
3.2 用Python实现TextRank关键词抽取的完整代码
项目里我用jieba提供分词和词性,自己实现图迭代部分,不直接调jieba.analyse.textrank,目的是能看清楚迭代细节。实际生产可以直接用封装好的接口,但理解实现有助于调参。
import jieba.posseg as pseg from collections import defaultdict import math class TextRankKeyword: def __init__(self, window=5, alpha=0.85, max_iter=200, tol=1e-4): self.window = window # 共现窗口大小 self.alpha = alpha # 阻尼系数 self.max_iter = max_iter # 最大迭代次数 self.tol = tol # 收敛阈值 def _build_graph(self, words): graph = defaultdict(set) # 窗口滑动构建共现关系 for i, word in enumerate(words): for j in range(i + 1, min(i + self.window, len(words))): graph[word].add(words[j]) graph[words[j]].add(word) return graph def extract(self, text, top_k=10): # 词性过滤 words = [] for word, flag in pseg.cut(text): if word.strip() and (flag.startswith('n') or flag.startswith('v') or flag.startswith('a')): words.append(word) graph = self._build_graph(words) # 权重初始化 scores = defaultdict(float) for word in graph: scores[word] = 1.0 # 迭代计算 for _ in range(self.max_iter): new_scores = {} for word, neighbors in graph.items(): score = 1 - self.alpha neighbor_sum = 0.0 for neighbor in neighbors: neighbor_out = len(graph[neighbor]) if neighbor_out > 0: neighbor_sum += scores[neighbor] / neighbor_out new_scores[word] = score + self.alpha * neighbor_sum # 收敛判断 diff = sum(abs(new_scores[w] - scores[w]) for w in scores) scores = new_scores if diff < self.tol: break result = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [w for w, _ in result[:top_k]] # 使用示例 text = "文本关键词抽取是自然语言处理的重要方向,关键词抽取的结果直接影响文本分类和搜索引擎的效果。" extractor = TextRankKeyword(window=5, alpha=0.85) print(extractor.extract(text, top_k=5))window和alpha是最值得调的两个参数。window控制共现范围,通用场景5到7比较稳妥;alpha是PageRank继承下来的阻尼系数,默认0.85,调大让权重传播更充分,调小让初始权重影响更大。max_iter一般100到200次就收敛,tol提供提前终止条件。
注意这里做的是无向图,因为共现关系天然是双向的。如果要追求更细粒度的方向性,可以引入文档内位置信息,比如首句出现的词获得更高初始权重,这属于TextRank的变体,不在今天的讨论范围。
3.3 TextRank在中文场景的调优与性能边界
TextRank的计算开销集中在图构建和迭代阶段。图构建的复杂度是$O(n \times window)$,迭代阶段每次要遍历所有节点和边。一篇5000字的文档,候选词大约1500个,共现边数可能在数万级别,纯Python实现大概需要几百毫秒。如果对延迟敏感,建议用networkx的重度优化版本,或者直接上jieba.analyse.textrank。
一个生产级的调优经验:在分词后合并“专有名词短语”。中文分词工具对“机器学习算法”这类组合词的切分不稳定,TextRank会把“机器”“学习”“算法”分别建图,丢失短语的整体语义。常见做法是把窗口内的连续名词用下划线拼接后合并成新词,再参与建图,效果提升明显。
TextRank的局限在长文本上尤其明显:文档越长,词间共现关系越密集,排序结果越接近词频统计。而且它仍然无法处理“语义近似但形态不同”的词,解决办法只能交给语义模型,也就是第三种方案。
4. 方法三:基于BERT的中文关键词抽取
4.1 为什么需要语义级关键词抽取
前面两种方法都在“词面”层面做统计,无法回答“这个词和另一个词是不是在说同一件事”。举个例子,同一篇关于云计算的文章可能会出现“虚拟机”和“弹性计算”,在TF-IDF和TextRank里是两个独立特征,但它们语义高度相关。基于BERT的抽取方案通过把词语映射成语义向量,再用向量相似度做聚类或判别,能显著改善同义词聚合问题。
但这个方案有一个前提:需要有一个能理解中文语义的预训练模型。最常用的是bert-base-chinese,或者效果更好的chinese-roberta-wwm-ext。抽取的基本思路不是直接让BERT输出关键词,而是把候选词和整个句子分别编码成向量,计算候选词与句子的语义相关度,相关度高的就是关键词。
这种方法的优势是零样本适应性强,同一个模型在领域差异很大的文档上都能保持基本稳定;劣势是推理成本高,且不能直接处理超长文本。BERT的输入上限是512个token,遇到长文档必须做切分或滑动窗口,这也是实际工程应用里最大的障碍。
4.2 用transformers实现基于BERT的关键词抽取
下面我给出一个基于sentence-transformers流派的思想实现,但直接使用transformers库更透明。核心流程分两步:先生成候选词向量和句子向量,再点积计算相关性得分。代码用中文短文本示例:
import torch from transformers import AutoTokenizer, AutoModel import jieba.posseg as pseg model_name = "hfl/chinese-roberta-wwm-ext" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModel.from_pretrained(model_name) def get_embedding(text): inputs = tokenizer(text, return_tensors='pt', max_length=512, truncation=True) with torch.no_grad(): outputs = model(**inputs) # 用[CLS]向量代表句子语义 return outputs.last_hidden_state[:, 0, :].squeeze() def extract_keywords_bert(text, top_k=5): # 候选词:词性过滤 + 名词优先 candidates = [] for word, flag in pseg.cut(text): if flag.startswith('n') and len(word) > 1: candidates.append(word) if not candidates: return [] sent_vec = get_embedding(text) results = [] for cand in set(candidates): cand_vec = get_embedding(cand) # 向量点积后取归一化相似度 score = torch.cosine_similarity(cand_vec.unsqueeze(0), sent_vec.unsqueeze(0)).item() results.append((cand, score)) results.sort(key=lambda x: x[1], reverse=True) return results[:top_k] text = "深度学习模型在自然语言处理任务中表现突出,尤其是基于Transformer架构的模型在文本分类任务中取得了显著成果。" print(extract_keywords_bert(text, top_k=5))这个实现的逻辑是:句子级向量用[CLS]位置的信息,它聚合了整个句子的语义;候选词单独编码后与[CLS]向量做余弦相似度,得分越高说明该词越能代表句子主题。
hfl/chinese-roberta-wwm-ext是全词掩码版本,对中文分词边界更友好,比直接使用bert-base-chinese效果略好。注意:每个候选词都要单独过一次模型,时间复杂度是$O(m \times n)$,其中$m$是候选词数量。所以必须先做候选词剪枝,把无关词性过滤掉,否则一篇中等长度的文档会有上百次推理。
4.3 BERT方案的工程化陷阱:向量抖动的处理
直接用上面的代码,你会遇到一个很现实的问题:候选词单独编码时,上下文信息完全丢失,导致一词多义时向量不稳定。比如“苹果”在“苹果发布新手机”和“苹果的价格上涨”中,单独编码的语义向量几乎一样,但句子向量会明显区主题差异。
常见的工程修复手段是上下文感知编码:把候选词放回原句,用词语在句子中的融合表示(如最后一层对应token的平均池化)作为该词的向量。这样“苹果”在不同句子里会获得不同向量。但这会引入新的复杂度:一个候选词在一句话中出现多次时向量也不同。我一般会取平均作为最终向量,稳定性和准确性之间的平衡比较好。
另外需要注意max_length=512的限制。对于长文档,建议先切句,逐句抽取关键词后再做跨句的得分聚合,这样既避免截断丢失信息,也能保持BERT的语义优势。这个方案不适合实时性要求高的接口,它更适合离线批量处理的语义分析场景。
5. 三种方法同台竞技:评测指标与选择策略
5.1 用精确率和召回率横向对比三种方法
三种方法在同样数据上跑出来会差异很大。下面是一组典型的中文技术文档测试结果(60篇摘要,人工标注关键词各约5个):
| 方法 | 精确率(P@5) | 召回率(R@5) | 处理耗时(每篇) | 领域适配成本 |
|---|---|---|---|---|
| TF-IDF | 0.42 | 0.25 | 15ms | 需要重新统计idf |
| TextRank(window=5) | 0.48 | 0.31 | 80ms | 调整窗口和停用词 |
| BERT+相似度 | 0.55 | 0.37 | 1.8s | 几乎零成本 |
结果符合预期:BERT方案在效果上有明显优势,但代价是三个数量级的耗时。这里的精确率是抽取5个关键词中命中人工标注的比例,召回率是命中的关键词数占人工标注总数的比例。
TF-IDF在耗时上碾压其他两种,性能瓶颈只在分词器上,适合做大规模文档的初筛。TextRank在效果和耗时之间取了折中,且不依赖外部语料库,适合中小规模数据上的快速原型。BERT方案性价比最低但上限最高,适合对语义要求严苛且允许离线预计算的业务场景。
5.2 按文本类型和业务要求选型的决策清单
如果你拿到一个新项目,不确定该用哪种方案,按下面的路径决策:
- 新闻和社交媒体短文本:优先TextRank,窗口调小到3,配合停用词过滤,效果优于TF-IDF且没有外部语料依赖。
- 长篇幅技术文档和论文:TF-IDF更可靠,原因在于TextRank在大图上的排序会退化成词频统计。先统计一个高质量idf文件,配合词性过滤,性价比最高。
- 用户查询日志或需要同义聚合的场景:直接上BERT方案,但建议用上一节提到的上下文感知编码,避免一词多义导致的向量偏移。
- 实时接口(响应要求小于100ms):只能选TF-IDF,TextRank要看文档长度,BERT大概率不行。
5.3 一个混合抽取的实用策略
生产系统里我经常看到两种或三种方法叠加使用。合理的叠加方式不是把三个结果直接取并集,而是以BERT结果为核心,用TF-IDF或TextRank做候选集扩展。具体做法是:先用TF-IDF取top 20候选词,过滤后送给BERT做语义相关性排序。这样既利用统计方法快速覆盖全局词汇,又用语义模型对候选词精排。
# 伪代码:混合抽取流程 candidates = tfidf_extractor.extract(text, top_k=20, with_weight=False) bert_results = [] for cand in candidates: score = bert_similarity(cand, text) # 复用4.2的相似度函数 bert_results.append((cand, score)) bert_results.sort(key=lambda x: x[1], reverse=True) final_keywords = [w for w, _ in bert_results[:5]]这个策略的巧妙之处在于:TF-IDF部分可以通过调整top_k控制召回率,Bert部分控制排序质量。如果BERT推理速度跟不上,还能对BERT打分结果加一层LR回归做缓存,命中历史查询就直接返回。混合方案通常能把F1值提升10到15个百分点,同时保持延迟在可接受范围内。
6. 停用词表、词性过滤与最终验证技巧
6.1 停用词表的维护方法论
停用词表不是一次构建终身受用。中文的停用词分三层:语法停用词(的、了、是、在)、语义弱化词(进行、通过、相关、基于)、领域噪音词(在不同领域里出现频率高但无区分度的词)。语法停用词直接用公开表即可,语义弱化词需要结合词性过滤和频率统计,领域噪音词必须根据你的语料动态生成,方法很简单:统计全量语料的词频,把出现在60%以上文档中的除名词和动词之外的词加入停用词表。
jieba自带的analyse.set_stop_words()接受一个停用词文件路径,但这个方法只作用于extract_tags和textrank,不影响分词结果。如果你自己实现了TF-IDF或TextRank解析器,停用词过滤要在分词后显式执行,比如前面代码里if word not in self.stopwords这一步。
6.2 词性过滤的两个细节:动词保留与英文处理
很多中文关键词抽取教程把词性过滤写成仅保留名词,这在技术文档场景是错的。技术文本里“部署”“升级”“优化”这些动词往往是核心信息,比如“部署步骤”“优化策略”。我通常保留n、v、a三类,去掉r(代词)、c(连词)和u(助词)。英文单词在jieba.posseg里会被标记为eng,如果想保留Python、API这类词汇,要单独放行。
allowed_flags = ('n', 'v', 'a', 'eng') if flag.startswith(allowed_flags[:3]) or flag == 'eng': pass6.3 用一致性验证替代人工抽查
模型调参最怕自我感觉良好。我推荐一个可量化的验证技巧:拿两份人工标注过的数据集,一份作为开发集用来调参,一份作为验证集固定不动。调参过程中只允许看开发集上的精确率和召回率,最终效果以验证集为准。这样可以防止过拟合参数。
另外可以用一个轻量的一致性指标做自动监控:把同一篇文档切成两半,分别抽取关键词,计算两半结果的Jaccard相似度。如果相似度长期低于0.3,说明抽取结果不稳定,通常是窗口大小或停用词策略出了问题。用这个指标配合日志监控,比人工每周看结果要可靠得多。
本文还有配套的精品资源,点击获取