关键词提取是自然语言处理中最基础也最实用的任务之一,无论是新闻摘要、搜索引擎索引,还是舆情监控和内容标签化,都离不开它。目前业内最常用的两种经典算法就是TF-IDF和TextRank,前者依赖统计特征,后者基于图排序思想,两者的适用场景差异很大。这篇文章会详细拆解两者的原理,给出完整的Python实现代码,并在多个维度上做横向对比,帮你搞清楚什么场景该用哪个。

TF-IDF的原理与实现
TF-IDF(Term Frequency-Inverse Document Frequency)的核心思想非常直观:一个词在某篇文档中出现得越频繁,并且在整个语料库的其他文档中越少出现,它就越可能是这篇文档的关键词。它由两个因子相乘得到:TF表示词在当前文档中的频率,IDF衡量词的区分能力。计算公式为:TF-IDF = TF × IDF,其中IDF通常取 log(N/DF),N是文档总数,DF是包含该词的文档数。如果一个词在所有文档中都出现(比如“的”“我们”这类高频虚词),DF接近N,IDF趋近于0,权重自然被压低。
这个算法的优点是计算简单、可解释性强,权重数值可以直接用来排序。但它有一个明显的硬性前提:必须有一个规模合理的语料库来计算IDF。如果语料库太小,IDF的估计会严重失真;如果处理的是单篇文档,TF-IDF会退化成纯粹的词频统计,效果大打折扣。此外,TF-IDF完全不考虑词与词之间的语义关系,纯粹依赖统计。
下面用Python手动实现TF-IDF关键词提取,中文场景需要先做分词和停用词过滤:
import math
import jieba
# 示例语料库:多个文档
documents = [
"人工智能技术正在改变搜索引擎的工作方式",
"深度学习模型在图像识别领域取得了突破性进展",
"搜索引擎通过机器学习优化排序算法",
"自然语言处理是人工智能的重要分支",
"图像识别与自然语言处理都属于机器学习范畴"
]
# 停用词表(实际项目中建议加载完整停用词文件)
stopwords = {"的", "是", "在", "与", "和", "都", "通过", "正在", "取得", "了"}
def tokenize(text):
# 分词并过滤停用词和单字词
return [w for w in jieba.lcut(text) if w not in stopwords and len(w) > 1]
# 构建语料库的文档频率
tokenized_docs = [tokenize(doc) for doc in documents]
N = len(tokenized_docs)
def tfidf_extract(doc_tokens, topk=5):
tf = {}
for w in doc_tokens:
tf[w] = tf.get(w, 0) + 1
scores = {}
for word, freq in tf.items():
# 计算包含该词的文档数
df = sum(1 for dt in tokenized_docs if word in dt)
idf = math.log(N / df) + 1 # 加1平滑,避免idf为0
scores[word] = (freq / len(doc_tokens)) * idf
return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:topk]
print(tfidf_extract(tokenize(documents[2])))
代码中IDF部分加了1做平滑,这是工程上的常见技巧,可以避免某些词的IDF恰好为0导致权重归零。实际项目中也可以直接使用scikit-learn的TfidfVectorizer,它内置了平滑参数和多种归一化方式,处理大规模语料时效率更高。
TextRank的原理与实现
TextRank的思想来源于Google的PageRank算法。它把文本看作一张图:每个词是图上的一个节点,词与词之间存在某种关系(通常是共现关系,即在一个固定大小的滑动窗口内同时出现)就连一条边。每个节点的得分由指向它的节点投票决定,得分为这些节点自身得分与转移概率的加权和,再乘一个阻尼系数d(通常取0.85)。迭代公式为:WS(Vi) = (1-d) + d × Σ (WS(Vj)/Out(Vj)),反复迭代直到收敛。
TextRank最大的优势是完全不需要语料库,只利用当前文档自身的结构信息就能算出关键词,这让它特别适合单文档、无预训练语料的场景。而且由于引入了词与词之间的共现关系,它能捕捉到一定的上下文结构信息,比如一个词如果总和多个不同的词共现,它的权重会被多路投票推高。缺点是图构建和迭代计算的开销比TF-IDF大,超长文本上性能会明显下降。
下面是TextRank的手写实现,重点看窗口共现建图和迭代两部分:
import jieba
from collections import defaultdict
def textrank_extract(text, topk=5, window=3, d=0.85, max_iter=200):
words = [w for w in jieba.lcut(text) if len(w) > 1 and not w.isspace()]
# 构建共现图
graph = defaultdict(lambda: defaultdict(int))
for i, w in enumerate(words):
for j in range(i + 1, min(i + window, len(words))):
graph[w][words[j]] += 1
graph[words[j]][w] += 1
# 初始化权重
ws = {w: 1.0 for w in graph}
out_sum = {w: sum(graph[w].values()) for w in graph}
# 迭代计算直到收敛
for _ in range(max_iter):
change = 0
for w in graph:
s = sum(ws[nw] / out_sum[nw] * weight
for nw, weight in graph[w].items())
new_ws = (1 - d) + d * s
change += abs(new_ws - ws[w])
ws[w] = new_ws
if change < 1e-6:
break
return sorted(ws.items(), key=lambda x: x[1], reverse=True)[:topk]
text = "自然语言处理技术中,关键词提取是文本挖掘的基础任务。" \
"关键词提取算法包括TF-IDF和TextRank,前者依赖语料库统计," \
"后者基于图排序思想,两者在关键词提取场景中各有优势。"
print(textrank_extract(text))
参数window控制共现窗口大小,窗口太小图会过于稀疏,窗口太大则会引入大量噪声边,经验值在2到5之间。阻尼系数d沿用PageRank的经典取值0.85,一般不需要调整。如果不想手写,jieba自带的analyse.textrank函数可以直接调用,内部实现逻辑与上面基本一致。
两者如何选择:多维度对比与实践建议
从语料依赖性来看,TF-IDF必须依赖外部语料库计算IDF,语料库质量直接决定效果上限,而TextRank零语料依赖,单篇文档即可运行。从计算效率看,TF-IDF只需一次词频统计加查表,复杂度接近线性;TextRank需要建图并迭代,复杂度明显更高,处理超长文档时可能成为瓶颈。从效果维度看,长文档上TextRank的图结构信息能发挥作用,效果往往更稳定;短文本由于词太少、图过于稀疏,TextRank容易失效,此时TF-IDF配合高质量语料库反而更可靠。
整理成表格更直观:
| 对比维度 | TF-IDF | TextRank |
|---|---|---|
| 语料依赖 | 需要外部语料库 | 无需语料库 |
| 计算复杂度 | 低,接近线性 | 较高,需建图迭代 |
| 长文本效果 | 依赖语料质量 | 稳定良好 |
| 短文本效果 | 语料好则较优 | 图稀疏,效果差 |
| 可解释性 | 强,权重有明确含义 | 中等,迭代收敛值 |
实践中有几条经验值得参考。第一,如果有稳定的大规模领域语料,优先用TF-IDF,并对IDF做定期更新,让它适应语言分布的变化。第二,处理单篇长文档、又没有现成语料时,直接上TextRank。第三,工业界的常见做法是两者融合:分别用两种算法各取一批候选词,取交集或加权融合排序,准确率通常比单一算法更高。第四,无论用哪种算法,中文分词质量和停用词表的完备程度对最终效果的影响,往往比算法本身的选择更大,这一步值得投入足够精力。
总结一下,TF-IDF和TextRank没有绝对的优劣,前者是统计派,靠语料说话;后者是结构派,靠文档自身的图结构说话。理解了各自的底层逻辑,再结合手头的数据条件和文本特点做选择,关键词提取这件事就能做到心里有数。如果后续想进一步提升效果,还可以考虑基于预训练模型的KeyBERT等方案,让语义理解能力参与进来,这也是当前关键词提取技术演进的主流方向。