中文关键词抽取算法对比:词频、TF-IDF与TextRank原理及Python实现
2026/9/23 1:20:52 网站建设 项目流程

简介:面向需要完成 NLP 课程设计或入门中文关键词抽取的读者,这套基于 Python 的实战资源围绕 TF-IDF、TextRank 与 Word2Vec 词向量聚类三种方法展开,包含课程论文、项目源码、实验数据与截图。压缩包共 31 个文件,其中 4 个 Python 脚本对应三种抽取算法的完整实现,14 个 CSV 为各方法输出的关键词结果,配套的 docx 论文、txt 停用词表与 PNG 截图可辅助理解原理和核对流程。课程论文从思路、原理到实现细节逐层讲解,并提出了类似增加专业语料、标题权重、按分类数调整聚类簇数等改进方向,能帮助读者快速掌握方法对比与实验设计思路。资源包仅 1.78MB,轻量易用,已有 2114 人学习下载,适合作为课程设计参考或 NLP 入门实践素材。

1. 中文关键词抽取不只靠TF-IDF,选错方法等于白做

有一次给客户做舆情系统的关键词功能,第一版用简单的词频统计,结果“记者”“报道”这种高频词直接霸榜,客户看了一眼就打了回来。后来换成TF-IDF,效果好了不少,但换到另一批语料又出现波动。最后把三种方法都接进去,让用户按场景自己切换,问题才算解决。

这个经历说明关键词抽取没有万能解。基于词频统计、TF-IDF、TextRank是Python生态里最常用的三套方案,分别适合不同的数据环境。这篇博客就把三者的原理、实现和调优讲透,附带可以直接改用的代码。无论是做课程设计还是实际项目,都能快速复现并判断该用哪一种。

在动手之前,建议先搞清楚一个概念:中文关键词抽取不是简单的“数词频”,它需要分词、去停用词、甚至词性过滤。理解了这一点,后面调参才不会慌。

2. 三种关键词抽取算法:词频、TF-IDF、TextRank的原理与对比

2.1 词频统计:最直观但最容易翻车

词频统计是关键词抽取的起点。先对文本分词,去掉标点和停用词,然后统计每个词的出现次数,取前N个。看起来简单,但有一个致命问题:高频词往往不是关键词,尤其是新闻语料里,“记者”“报道”“今天”这类词出现的频率远超真正有信息量的词。

下面是一个用Python实现词频统计的最小示例:

from collections import Counter import jieba def tf_topk(text, stopwords, topk=10): words = [w for w in jieba.cut(text) if w.strip() and w not in stopwords and len(w) > 1] counter = Counter(words) return counter.most_common(topk)

这段代码先用jieba把文本切成词,再用列表推导式过滤掉停用词和单字词,最后用Counter统计并返回频率最高的topk个词。参数stopwords是一个set对象,这里用len(w) > 1过滤单字,是因为很多无关的虚词都是单字。但即使做了这些,词频统计只反映“在文档里出现的绝对次数”,并不考虑这个词在所有文档里的分布。如果拿它去做全网站的关键词排行,结果会被通用词霸占。所以它只适合对单文档做临时观察,不适合跨文档的严肃分析。

2.2 TF-IDF:用逆向文档频率压住通用词

TF-IDF的思路是:一个词在文档里出现得多(TF高),但在其他文档里出现得少(IDF高),它才具有区分度。公式是:

TF-IDF = TF × log((N + 1) / (DF + 1)) + 1

其中N是语料库文档总数,DF是包含该词的文档数。关键点在于IDF:它把“的”“了”“记者”这类几乎每篇文档都会出现的词的权重压到很低。也就是说,TF-IDF需要一份语料才能算IDF——这是它和纯词频最大的区别,也是它的适用前提:必须有一个能代表目标场景的文档集合。

在Python生态里,主要用jieba.analyse.extract_tags来算TF-IDF。它内置了一套IDF语料,但如果你的文本领域性很强(比如医学、法律),最好用自己的语料重新训练IDF。这个我们后面在实现章节会讲。

2.3 TextRank:用图模型和PageRank排序

TextRank本质是把文档看作一个词图。先对文本分词、去停用词,然后假定一个窗口大小(比如5),窗口内任意两个词之间都有一条边。边的权重可以是共现次数。然后像PageRank一样迭代计算每个词的重要性。

TextRank最大的优势是不依赖外部语料库,单篇文档就能跑。它适合长文本,但对很短的一句话效果不稳定,因为共现关系太稀疏。另外,TextRank默认会把所有词都放进去,如果不限制词性,容易把介词、副词也排进来,所以一般要配合词性过滤。

2.4 三种方法的选型对比

方法核心思想是否需要语料库虚词过滤单文档效果跨文档效果
词频统计统计绝对次数不需要依赖人工停用词
TF-IDF词频×逆文档频率需要自动+人工一般
TextRank图模型迭代不需要需额外词性过滤一般

从选型角度看:课程设计最稳的是TF-IDF+TextRank做对比实验,再加一个词频统计作为baseline,正好构成三种方法。如果你的项目只有一个文档,且没有背景语料,选TextRank;如果有一批文档且领域明确,选TF-IDF;只有想快速出个草稿,才用纯词频。

3. 基于Python和jieba快速实现TF-IDF与TextRank关键词抽取

3.1 环境准备:Python、jieba与停用词表

开始之前先确认环境。关键词抽取不涉及GPU,普通Python 3.6+即可,但建议用虚拟环境。安装依赖只需要jieba,命令如下:

pip install jieba

如果你的网络环境装不上,可以换国内镜像源,这里不展开。另外需要一份停用词表。常见做法是准备一个stopwords.txt,每行一个词,包含“的、了、是、我、你、他”这种高频虚词。读取代码如下:

def load_stopwords(path='stopwords.txt'): with open(path, encoding='utf-8') as f: return set(line.strip() for line in f)

注意文件编码必须utf-8,否则在Windows下会报编码错误。读取后转成set,后续判断词是否在停用词表里的时间复杂度为O(1)。

3.2 调jieba.analyse的TF-IDF接口

jieba自带了TF-IDF实现,入口是analyse.extract_tags。一个最小调用示例:

import jieba.analyse def tfidf_extract(text, topK=10): jieba.analyse.set_stop_words('stopwords.txt') return jieba.analyse.extract_tags( text, topK=topK, withWeight=True, allowPOS=('ns', 'n', 'vn', 'v') )

参数说明:

  • topK:最终保留多少个关键词,课程设计一般取10到20。
  • withWeight:设为True时返回(词, 权重)元组列表,方便后续画图。
  • allowPOS:允许出现的词性,元组形式。'ns'是地名,'n'是名词,'vn'是动名词,'v'是动词。这能过滤掉形容词、副词。

这里调用了set_stop_words,它的作用是告诉jieba在内部计算时忽略停用词表中的词。如果不设置,extract_tags也会使用内置的默认停用词表,但内置表偏小,建议显式传入自己的领域停用词。输出形式类似:[('深度学习', 0.45), ('神经网络', 0.32), ...],权重越大代表越重要。

3.3 调jieba.analyse的TextRank接口

TextRank同样有现成接口,就是analyse.textrank。但它默认的窗口是5,而且支持直接传窗口大小?实际上要改窗口大小,需要构造TextRank类:

from jieba.analyse import TextRank def textrank_extract(text, topK=10): tr = TextRank(span=5) tr.set_stop_words('stopwords.txt') return tr.textrank(text, topK=topK, withWeight=True)

TextRank的构造参数:

  • span:共现窗口大小,默认5。窗口越大,词与词之间的关联越泛化;窗口越小,越容易找到紧邻的词对。对新闻文本,5表现较好;对代码片段,可以尝试3。
  • allowPOS:默认('ns', 'n', 'vn', 'v'),如果不想分词性,可以置为None。

这里用tr.set_stop_words()设置停用词表,作用方式和TF-IDF的一致。注意:textrank返回的关键词权重之和并不等于1,只用于内部排序,不要拿不同文档的权重跨文档比较。

3.4 三种方法封装到一个函数里

课程设计通常要求对比三种方法。把它们封装成一个统一入口比较合适:

from collections import Counter import jieba.analyse from jieba.analyse import TextRank def extract_keywords(text, method='tfidf', topK=10, stopwords_file='stopwords.txt'): jieba.analyse.set_stop_words(stopwords_file) if method == 'tfidf': return jieba.analyse.extract_tags(text, topK=topK, withWeight=True) elif method == 'textrank': tr = TextRank(span=5) tr.set_stop_words(stopwords_file) return tr.textrank(text, topK=topK, withWeight=True) elif method == 'tf': stopwords = load_stopwords(stopwords_file) words = [w for w in jieba.cut(text) if w.strip() and w not in stopwords and len(w) > 1] return Counter(words).most_common(topK) else: raise ValueError("method must be 'tfidf', 'textrank' or 'tf'")

这里几个细节要注意:set_stop_words相当于全局配置,多次调用会互相覆盖;如果同时用TF-IDF和TextRank,可以在每次调用前都设置一次。纯词频方法没有用analyse,所以必须自己加载停用词。这样封装后,调用者只需一行:extract_keywords(text, 'tfidf', 10)

为了直观对比三个接口的差异,可以看下面这个表:

方法核心接口关键参数返回值
TF-IDFjieba.analyse.extract_tagstopK, withWeight, allowPOS词+权重列表
TextRankjieba.analyse.textrankspan, withWeight, allowPOS词+权重列表
词频统计自写Counter统计topK, 停用词词+频次列表

3.5 常见坑:停用词失效与词性过滤过度

第一个坑是set_stop_wordstextrank生效吗?生效,但只对后创建的TextRank实例生效。所以上面代码中先set_stop_words再构造TextRank,顺序不能反。

第二个坑是allowPOS过滤太狠。比如你想保留“平静”这种形容词,但allowPOS里没有'a',它会直接被丢掉。课程设计报告里如果出现“结果不合理”,大部分都是这个原因。

第三个坑是jieba内置的IDF语料和你的文本领域不匹配。这个会在无法用内置接口时出现,我们下一章手写TF-IDF就是为了解决这个问题。

4. 不依赖高级API:从零手写TF-IDF和TextRank排序

4.1 分词与去停用词是成败关键

调用封装接口很快,但课程设计要想拿高分,最好能展示你理解内部逻辑。手写版本的预处理和前面一样,但需要注意三点:一是自定义词性过滤规则时,优先保留名词和动名词;二是文本过长时,建议分句再合并共现关系;三是对英文单词和数字要单独处理,通常直接剔除。

下面是一个完整的预处理函数:

import jieba.posseg as pseg def preprocess(text, stopwords, keep_pos=('n', 'v', 'vn', 'ns')): result = [] for word, pos in pseg.cut(text): if word in stopwords or len(word.strip()) < 2: continue if pos and pos not in keep_pos: continue result.append(word.lower()) return result

这里用了jieba的词性标注接口,把pos作为过滤条件。keep_pos控制了保留哪些词性,stopwords是set。返回的是一串小写词列表,方便后续共现统计。注意:这个预处理同时服务TF-IDF和TextRank,差异只在排序阶段。

4.2 手写TF-IDF:自己控制IDF语料

手写TF-IDF的核心在于怎么组织语料。假设我们有一个documents列表,里面是若干篇文档的原始文本。先对每篇文档预处理,建立word->文档出现次数(DF)的映射:

import math from collections import defaultdict def build_idf(documents, stopwords): df = defaultdict(int) for doc in documents: words = set(preprocess(doc, stopwords)) for w in words: df[w] += 1 n = len(documents) idf = {w: math.log((n + 1) / (c + 1)) + 1 for w, c in df.items()} return idf

代码逻辑:对每篇文档的预处理结果去重后,统计每个词出现在多少篇文档中(df)。然后按平滑公式计算IDF,分母加1防止除零,整体加1防止IDF为0。这里用(n + 1) / (c + 1)是常见平滑写法,你也可以换成math.log(n / (c + 1)),只是当c=0时不会报错但结果会偏小。

有了IDF,抽取某篇文档的关键词就简单了:

def extract_tfidf_manual(text, idf, stopwords, topK=10): words = preprocess(text, stopwords) tf = Counter(words) total = len(words) scores = {w: (tf[w] / total) * idf.get(w, 0.0) for w in tf} return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:topK]

TF部分归一化到词总数,这样长文档与短文档之间的分数才有可比性。idf.get(w, 0.0)处理了未见过的词——如果某词不在IDF表里,它的IDF记为0,相当于认为它对区分文档没有贡献。

4.3 手写TextRank:窗口共现与迭代排序

TextRank的手写实现比TF-IDF复杂一些。主要步骤是:先对文档预处理得到词序列,然后用一个滑动窗口收集词对,构建无向图,最后迭代计算权重。代码如下:

def textrank_manual(words, span=5, max_iter=100, d=0.85): graph = defaultdict(set) for i in range(len(words)): for j in range(i + 1, min(i + span, len(words))): graph[words[i]].add(words[j]) graph[words[j]].add(words[i]) scores = {w: 1.0 for w in graph} for _ in range(max_iter): new_scores = {} for node in graph: s = 1 - d for neighbor in graph[node]: s += d * scores[neighbor] / len(graph[neighbor]) new_scores[node] = s scores = new_scores return sorted(scores.items(), key=lambda x: x[1], reverse=True)

这段代码使用集合来存储邻居,避免重复边。初始化所有节点权重为1.0,逐轮按PageRank公式更新。d是阻尼系数,默认0.85,经验值。循环中的关键公式:1 - d是基础概率,邻居节点把它的权重均分给其所有邻居,当前节点累加。需要注意:这个实现没有把词频权重加入初值,也没有限制窗口内的距离权重。工程上更精细的做法是让词对的距离越近权重越高,比如权重设为1 / 距离。简单版本已足够课程设计展示。

4.4 手写实现与jieba封装的结果差异与调参

手写和jieba封装的结果通常会有差别,原因主要有四个:

差异点原因如何调整
分词词性不同手写只保留n,v,vn,ns,jieba可能保留英文调整keep_pos或allowPOS
IDF语料不同jieba内置语料来自网络文本,手写来自小语料增大语料规模
TextRank细节不同jieba对窗口距离有权重衰减,手写是纯布尔共现引入距离或词频做边权重
停用词表不同内置表和自定义表不完全一致统一停用词表

当实验数据出现“jieba抽取效果不如手写”时,不要急着说算法不好,先检查这四项。调参建议:TF-IDF的TopK取10-20,IDF平滑常数从1改成0.5可以放大低频词权重;TextRank的span取5是标准值,文本较短时改成3,max_iter默认100已经收敛不用改,d在0.8到0.9之间微调;预处理不要贪多词性,保留名词和动词就足够,停用词表宁缺毋滥,否则误删专有名词。

5. 课程设计验收技巧:用三种方法做对比实验并展示结果

5.1 用Jaccard系数量化三种方法的结果差异

课程设计验收时,除了跑通代码,还要能回答“三种方法有什么不同”这个问题。我的做法是准备两份语料:一份新闻类、一份小说类,各取50篇作为IDF来源,另外选3篇作为测试文档。对同一篇文档分别调用封装函数和手写函数,抽取top10,然后计算两两之间的重叠度(Jaccard系数)。

def jaccard(a, b): sa, sb = set(a), set(b) if not sa and not sb: return 1.0 if a == b else 0.0 return len(sa & sb) / len(sa | sb) for text_id, test_text in enumerate(test_texts): k_tfidf = [w for w, _ in extract_keywords(test_text, 'tfidf', 10)] k_textrank = [w for w, _ in extract_keywords(test_text, 'textrank', 10)] print(text_id, 'overlap:', jaccard(k_tfidf, k_textrank))

Jaccard值一般在0.2到0.5之间都属于正常,因为两者的排序逻辑完全不同。如果重叠度超过0.7,说明分词和停用词已经主导了结果,算法的差异没有体现出来,这时要调整IDF语料或TextRank窗口。

5.2 用权重条形图提升答辩效果

展示结果时,我习惯用matplotlib把权重条形图画出来,横排关键词,纵排权重:

import matplotlib.pyplot as plt def show_weights(keywords): words, weights = zip(*keywords) plt.barh(range(len(words)), weights, tick_label=words) plt.gca().invert_yaxis() plt.show()

注意中文字体问题:Windows下要设置plt.rcParams['font.sans-serif'] = ['SimHei'],否则汉字会变方块;macOS下可以改成['Arial Unicode MS']。另外,报告里不要只贴结果,建议设计一个人工评估表:让同组同学对每一篇文档按“是否属于关键信息”给每个候选词打分,最后算出三类方法的平均准确率。这个指标不复杂,但能说明你有评估意识。把三种方法封装成命令行或简单GUI,用同一个输入框切换radioButton,也能显著提升答辩效果,不需要复杂框架,tkinter就够用。

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

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

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

立即咨询