聊到英文文本关键词提取,TF-IDF 是绕不开的一个名字。我在不同项目里用它做过文章标签推荐、工单分类预筛、搜索词扩展,也用它给用户反馈做热点聚类的前置过滤。这个公式看起来简单,但真正用 Python 和 Sklearn 把它落地时,藏在预处理、参数和语料里的细节,能直接影响最终结果质量。这篇就围绕一个主题:用 Python + Sklearn 完成英文文本的关键词提取,从原理讲到参数调优,再到完整可运行的代码,中间穿插我实际跑数据时踩过的坑。适合刚接触文本挖掘的人,也适合已经写过几版但总感觉提取结果不对的开发者。
1. 为什么偏偏是 TF-IDF:关键词提取的核心思路
1.1 从一个具体问题说起
给你一批英文文档,比如几十篇新闻、几段客服对话,或者一批商品评论,想快速知道每一篇在讲什么。最直觉的办法是数词频:哪个词出现得多,哪个词就重要。但这个词频方式有个明显漏洞:很多词在所有文档里都高频,比如the、and、of,它们没有任何区分度,也不能代表文档主题。
反过来,有些词只在一两篇文档里出现,却能精准指认主题。比如一篇讲人工智能的文章里反复出现machine learning,而它在整个语料的其他文档里几乎不出现,那这个词就很有可能是这篇文章的关键词。TF-IDF 就是把这两股力量拧在一起的经典权重方案。
这里可以打个生活化的比方。想象一个班级,老师喊一声“同学”,所有学生都会回头;但喊“张三”,只有一个人回头。TF 相当于“张三”在你的文档里出现过几次,IDF 相当于“张三”这个名字在全年级有多稀有。两者结合,既能反映词在文档里的重要性,又能反映词在整个语料里的辨别能力。
1.2 TF-IDF 公式拆解
TF-IDF 是两个部分的乘积。
TF 是词频,表示词在文档中出现的次数。常见的计算方式有三种:原始计数、词频除以文档总词数、以及对数化1 + log(tf)。Sklearn 默认用的是原始计数,然后在向量化时按行做 L2 归一化。这点和很多教科书里写的“TF = 词频 / 文档总词数”不太一样,需要特别留意。
IDF 是逆文档频率,表示词在多少篇文档中出现过。Sklearn 的实现是:
idf = ln((1 + N) / (1 + df)) + 1这里的N是语料中的文档总数,df是包含该词的文档数。加 1 是平滑项,防止某些词的 df 为 0 时除零,同时避免 IDF 出现负值。为什么用ln?因为文档频率按倍数增长时,额外信息量的增长是线性的,对数压缩刚好符合这种直觉。
举个具体的例子。假设语料有 4 篇文档,词fox只出现在文档 1 里,那么N=4,df=1,idf = ln(5/2) + 1 ≈ 1.916。如果fox在文档 1 里出现了 2 次,那它的原始 TF-IDF 权重就是2 × 1.916 ≈ 3.83。当然,Sklearn 后续会按行做 L2 归一化,最终的输出会变成文档向量中的相对数值,但排序逻辑基本就是“词频高、文档频率低”的词排在前面。
1.3 和其他关键词提取方案的对比
TF-IDF 不是唯一选择。做关键词提取时,还经常遇到 TextRank、LDA、基于深度学习的方法。它们各有定位:
| 方案 | 核心原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| TF-IDF | 词频 × 逆文档频率 | 无监督、速度快、可解释强 | 不捕获语义和词序 | 快速基线、标签抽取、检索相关 |
| TextRank | 基于共现图做 PageRank 排序 | 适合单文档,能利用词共现关系 | 需要建图,结果受窗口大小影响 | 单篇长文本关键词提取 |
| LDA | 主题模型推断词的主题分布 | 能发现潜在主题 | 需要指定主题数,推理较慢 | 文档主题探索、聚类预筛 |
| 深度语义模型 | 用预训练模型算词与文档的相关度 | 语义理解强 | 依赖模型和算力,结果不可控 | 复杂语义场景,重排序 |
实际项目中,如果没有标注数据、不想引入过大模型,从 TF-IDF 开始是性价比最高的做法。它给出的结果能作为基线,也能帮助快速发现语料质量问题。
2. 动手前的准备:环境搭建与语料选择
2.1 Python 环境与依赖安装
建议直接用 Python 3.8 以上的版本,配合虚拟环境管理依赖。创建一个新目录后执行:
python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install scikit-learn只要装了 scikit-learn,就自带英文停用词表和TfidfVectorizer,不需要额外安装 NLTK。这意味着你可以用极少依赖跑通整个流程。装完后验证一下版本:
import sklearn print(sklearn.__version__)如果版本在 1.0 以上,特征名获取使用get_feature_names_out();更老的版本要改用get_feature_names()。我建议直接在 1.0 以上的环境里操作,能少踩不少兼容性的坑。
2.2 英文文本预处理的那些坑
英文文本的预处理是决定关键词质量的前置条件。这里梳理几个容易踩坑的点。
第一,大小写归一化。Apple和apple如果不归一会变成两个特征。大部分场景下应该统一转成小写,但如果你的业务里专有名词大小写有严格含义,就要单独考虑。
第二,标点符号和特殊字符。最简单的方式是用正则在所有非字母字符位置换成空格,但这一步会破坏缩写。比如don't如果按[^a-z\s]替换,会变成don t,拆成两个词。如果你的文本里缩写很多,要么提前把缩写展开,要么用自定义 tokenizer 保留[']。
第三,连字符和数字。state-of-the-art按默认 token 规则会被拆成state、of、the、art四个词。2024、3.14、word_count这些 token 在默认token_pattern下可能会被保留或拆分,需要根据场景决定是否保留。
第四,停用词。英文里有 100 多个高频无实义的词,比如the、is、at、which。Sklearn 的stop_words="english"内置了一个词表,但它不一定覆盖所有业务里的噪声词。
多年经验下来的一个习惯是:第一版先只做“小写 + 去标点 + 去停用词”,把结果跑出来人工扫一遍。看到明显噪声再迭代。不要一开始就上很重的预处理,否则很难定位问题。
2.3 语料怎么选:单文档和多文档场景的区别
很多人第一次用 TF-IDF,会拿一篇文章去跑,然后发现结果基本等于词频排序。原因很简单:当语料只有一篇文档时,每个词的文档频率都是 1,IDF 完全一样,区分度就没了。
所以,只要你的目标是从单个文档里提取关键词,就必须给它配一个“背景语料”。这个背景语料最好来自同一来源、同一领域。比如你处理的是科技新闻,就用一批科技新闻文章作为背景;你处理的是产品评论,就用一批同类型产品评论作为背景。背景语料不需要很干净,数量上几十篇就比一篇文章好得多。
反过来,如果你手里本来就是一批文档,想给每一篇提取关键词,那这批文档本身就是语料。此时的逻辑是:每篇文档的词频权重加上整个语料的逆文档频率,共同决定关键词。这种情况下,语料不要选得太杂,比如用新闻做背景去提取论文关键词,效果通常不会好。
3. 完整可运行代码:从预处理到关键词输出
3.1 TfidfVectorizer 核心参数速览
Sklearn 的TfidfVectorizer本质上是先做词频统计,再计算 IDF 权重,最后输出稀疏矩阵。它的核心参数如下:
| 参数 | 默认值 | 作用 |
|---|---|---|
lowercase | True | 是否转小写 |
stop_words | None | 内置"english"或自定义词表 |
token_pattern | r"(?u)\b\w\w+\b" | 默认只保留长度大于等于 2 的词 |
ngram_range | (1, 1) | 提取单个词还是词组 |
max_df | 1.0 | 文档频率超过该比例则忽略 |
min_df | 1 | 文档频率低于该值则忽略 |
norm | "l2" | 向量归一化方式 |
sublinear_tf | False | 是否使用1+log(tf) |
smooth_idf | True | IDF 平滑,避免除零 |
第一次接触时不要被这么多参数吓到。你只需要记住:ngram_range控制要不要组词,max_df和min_df控制词表范围,stop_words控制噪声词。后文会逐一讲怎么调。
3.2 完整代码逐段讲解
下面这段代码可以直接复制运行。它会对 4 篇短英文文档提取每个文档的 Top 关键词。
import re from sklearn.feature_extraction.text import TfidfVectorizer raw_docs = [ "The quick brown fox jumps over the lazy dog. A fox is smart and quick. The dog barks loudly.", "The lazy dog sleeps all day. Dogs are loyal animals and great companions.", "Artificial intelligence is transforming the world. Machine learning is a subset of artificial intelligence. Deep learning is a part of machine learning.", "Natural language processing helps computers understand text. Machine learning powers natural language processing and text analysis.", ] def clean_text(text): # 统一小写,把所有非字母字符替换为空格,再合并多余空格 text = text.lower() text = re.sub(r"[^a-z\s]", " ", text) text = re.sub(r"\s+", " ", text).strip() return text docs = [clean_text(doc) for doc in raw_docs] vectorizer = TfidfVectorizer( stop_words="english", ngram_range=(1, 2), max_df=0.9, min_df=1, sublinear_tf=True, ) tfidf_matrix = vectorizer.fit_transform(docs) feature_names = vectorizer.get_feature_names_out() def extract_keywords(doc_index, matrix, names, top_n=5): scores = matrix[doc_index].toarray()[0] ranked = sorted( [(names[i], round(float(scores[i]), 4)) for i in range(len(names)) if scores[i] > 0], key=lambda x: x[1], reverse=True, ) return ranked[:top_n] for i in range(len(docs)): print(f"Document {i+1}: {raw_docs[i]}") print("Top keywords:", extract_keywords(i, tfidf_matrix, feature_names)) print()运行后,每个文档会输出一个关键词列表。数字会随 sklearn 版本有细微差别,但排序关系基本一致。比如第一篇的 Top 关键词大概率包含fox、quick、dog这一类词;第三篇会包含artificial intelligence、machine learning这类主题词。
这段代码里有几个值得注意的地方。max_df=0.9表示如果某个词在超过 90% 的文档里出现,就把它排除掉。因为语料只有 4 篇文档,超过 90% 意味着几乎每一篇都有,这样的词区分度很低。sublinear_tf=True会把词频做对数平滑,避免某个词出现 10 次时权重是出现 2 次时的 5 倍。ngram_range=(1, 2)同时保留单词和二元短语,适合英文里大量存在的复合名词。
extract_keywords函数内部把稀疏矩阵的某一列转成数组,按分数降序排列,再取前 N 个。注意matrix[doc_index]拿到的是第几行文档向量,toarray()[0]转成普通数组。因为稀疏矩阵不能直接下标取值,所以这个转换是必须的。
3.3 输出结果解读
看到分数时,要知道 TF-IDF 的分数是相对值,不是概率。不同文档之间的分数不能直接比较,因为每一行都做了 L2 归一化,向量长度不同,绝对值也不同。你只需要在同一个文档内对关键词排序。
另一个常见现象是machine和machine learning同时出现在 Top 列表里。这是因为我们用了(1, 2)的 ngram 范围,单词和二元短语都是独立特征。业务上如果只想要短语,可以把ngram_range改为(2, 2)。如果既想要单词也想要短语,保留当前设置就好,只是输出时要注意去重展示。
另外,词频高但区分度低的词不一定会排在前面,区分度高但词频很低的词也可能是关键词。比如某篇文档里只出现了一次生僻术语,但它在整个语料里几乎不出现,它的 IDF 会很大,分数可能依旧靠前。这个特性意味着 TF-IDF 对长尾主题词很敏感,对高频常见词的压制也做得不错。
4. 参数调优与效果提升:让关键词更准的 5 个实操技巧
4.1 ngram_range:从单词到短语
英文文本里的关键词很多不是单个词,而是短语。machine learning、artificial intelligence、customer service都是典型的例子。默认的ngram_range=(1, 1)只会提取单个词,容易把语义割裂。调成(1, 2)之后,短语会被作为一个整体纳入词表。
代价是特征数量会明显增加。假设语料里有 1 万个不同单词,理论上二元组合可能有几百万个,虽然实际不会全部出现,但维度依然很大。建议先看单文档提取结果,如果发现主题明显是短语,再调成(1, 2)。如果语料很大,还可以用max_features限制总特征数量。
另外需要注意,TfidfVectorizer生成 ngram 时,默认会先用正则切词,再做停用词过滤。如果你不删除停用词,会产生大量the code、of data这类没有意义的短语。所以先用stop_words做一轮过滤,再让 ngram 在剩余的 token 上组合,效果会好很多。
4.2 max_df 与 min_df:过滤高频噪声和低频生僻词
max_df和min_df是控制词表范围的两个旋钮。
max_df用来过滤文档频率过高的词。比如整个语料里 80% 的文档都包含data,那它在区分文档主题时基本没用。设置max_df=0.8后,这个词直接不进入词表。min_df用来过滤文档频率过低的词。如果某个词只在 1 篇文档里出现,而且只出现 1 次,它很可能是拼写错误或噪声,设置min_df=2可以把它过滤掉。
这两个参数不是越大或越小就好。在只有几十篇的小语料里,min_df=2已经能滤掉很多噪声;在大规模语料里,min_df=5甚至min_df=10都不过分。关键是看你的业务数据量级和噪声程度。
我实际操作里的一个经验是:先跑一版默认参数,把词表数量和 Top 关键词打印出来,然后根据词表里明显的噪声词反推max_df和min_df。比如词表里出现太多只在单篇里出现的数字串,就提高min_df;出现太多任何文档都有的通用词,就降低max_df。
4.3 自定义停用词表:领域词的取舍
Sklearn 内置的stop_words="english"覆盖的是通用停用词,比如the、is、and。它不会覆盖你所在领域的噪声词。比如在新闻文本里,said、according、reported可能高频出现,但它们往往不是关键词;在工单场景里,issue、please、need也可能是干扰词。
可以把内置词表和自定义词表合并:
from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS custom_stop = ENGLISH_STOP_WORDS.union( {"said", "according", "reported", "article", "also", "however"} ) vectorizer = TfidfVectorizer( stop_words=list(custom_stop), ngram_range=(1, 2), )这里有个容易踩坑的点:直接传stop_words=["said", "according"]会覆盖内置词表,而不是追加。也就是说the、and这些词又回来了。所以如果要保留内置停用词,务必用ENGLISH_STOP_WORDS.union(...)。
自定义词表需要根据输出结果持续迭代。任何你觉得“高频但没信息量”的词,都可以往里加。但不建议一开始就把词表堆得很厚,否则可能把有价值的领域词也误删。
4.4 词形还原 vs 词干提取:什么时候必须上
TF-IDF 不会把learn、learned、learning合并。如果一个语料里同时出现这些形态,它们会变成 3 个独立特征,稀释真正主题词的权重。要处理这个问题,需要用词干提取或词形还原。
词干提取速度快,但输出不一定是合法单词。比如 PorterStemmer 会把computers变成comput,把studies变成studi。词形还原输出的是词典里的合法词形,但需要额外的词性标注和词典数据,速度更慢。
给一个用 PorterStemmer 做词干提取的参考:
from nltk.stem import PorterStemmer stemmer = PorterStemmer() def stem_tokenizer(text): tokens = re.findall(r"[a-z]+", text.lower()) return [stemmer.stem(token) for token in tokens]然后把stem_tokenizer传给TfidfVectorizer(tokenizer=stem_tokenizer)。只要你传入自定义tokenizer,默认的token_pattern就不再生效,这一点要记住。
我的建议是:如果语料主题词形态变化明显,优先做词形还原而不是词干提取,因为输出结果更利于人工阅读。如果只是做聚类或检索的前置特征,词干提取完全够用。
4.5 归一化与子线性 TF 的适用场景
sublinear_tf=True会把词频改成1 + log(tf)。这个处理背后的逻辑是:一个词在文档里出现 100 次,它的信息量不应该是出现 1 次时的 100 倍。对数压缩能防止长文档里高频词过分主导关键词排序。对于新闻、论文、客服记录这类长短差异明显的文本,我一般都会打开。
norm="l2"是默认的向量归一化方式。它会将每个文档向量除以 L2 范数,使得所有文档向量具有相同的长度。这样做的好处是,不同篇幅的文档在计算相似度或比较关键词相对重要性时更具可比性。
norm=None在什么场景下有用?如果你希望保留词语在文档中的绝对频次信息,比如做关键词分类时要考虑文档长度,可以关闭归一化。但大多数关键词提取任务不需要这么做,保持默认即可。
这组参数看起来不复杂,但组合起来效果差异很大。一个比较稳健的起始配置是:sublinear_tf=True、smooth_idf=True、norm="l2"、ngram_range=(1, 2)。如果没有特别理由,这些参数不用天天调,先把重点放在预处理和停用词上。
5. 常见问题与排查实录
5.1 空词汇表警告:为什么输入全是停用词
有时运行fit_transform会看到警告:empty vocabulary; perhaps the documents only contain stop words。这表示经过切词和停用词过滤后,词表里没有任何词。
常见原因有三个。第一,文档里全是数字、符号或特殊字符,大小写归一化后剩余内容为空。第二,把所有词都用自定义停用词过滤掉了,常见于停用词表设置过狠。第三,文档语言与 token 规则不匹配,比如中文文本放到默认的token_pattern里,几乎切不出有效 token。
排查方式很简单:把语料抽样打印出来,直接看clean_text之后的内容是否还有词。再打印停用词表的大小,确认是否误伤太多。如果只是 token 规则问题,可以换用自定义 tokenizer 提取[a-z]+。
5.2 单文档结果退化成词频
我见过最多次的求助,就是“我用 TF-IDF 提取一篇文章的关键词,结果出来的全是高频常见词”。这其实是用法问题,不是算法问题。
当文档集合总共只有一篇文章时,每个词的文档频率都是 1,IDF 完全相等,TF-IDF 就退化成 TF。此时应该补充背景语料。最简单的方式是把目标文档放到一个更大的同类文档集合里,用fit_transform一次性算完,再单独取出目标文档那一行。
如果实在没有背景语料,可以临时用无监督的 TextRank 或者直接在词频结果上叠加一个词性过滤,只保留名词和形容词。但这些都是权宜之计,最根本的办法还是准备一个领域相关的语料集合。
5.3 结果里出现数字、下划线和无意义符号
默认的token_pattern=r"(?u)\b\w\w+\b"中,\w会匹配字母、数字和下划线。所以2024、abc_def、version_2这类 token 会被保留。对于大多数关键词提取任务,这都是噪声。
解决办法是用自定义 tokenizer 只提取字母序列:
def letter_tokenizer(text): return re.findall(r"[a-z]+", text.lower())如果你还是想保留年份、版本号这类信息,可以对数字做白名单处理,而不是一刀切删掉。另外,英文缩写如don't、can't如果用[a-z]+会拆成don和t。如果业务中缩写很重要,建议改成re.findall(r"[a-z]+(?:'[a-z]+)?", text.lower()),把don't作为一个 token 保留,并在自定义停用词里补充don't、it's等。
5.4 特征维度爆炸与性能变慢
处理上万篇文档时,ngram_range=(1, 2)可能轻松带来几十万甚至上百万维特征。矩阵虽然是稀疏的,但内存和计算时间依然会快速上升。
我处理过一批十万级的新闻语料,第一版用的ngram_range=(1, 2)、min_df=1,特征数量直接到百万级别,单机内存差点被吃满。后来把min_df提到 3,max_df降到 0.8,特征数量瞬间降到二十万左右,效果反而更稳定。
如果语料规模继续扩大,还可以考虑使用HashingVectorizer,它通过哈希技巧把特征限制在预设维度,不需要维护完整词表。不过它的缺点是特征名不可读,适合做模型特征,不太适合直接输出关键词。对于最终要展示关键词的任务,更好的方案是先用min_df/max_df控制词表规模,再配合max_features硬性限制维度。
另外要注意,TfidfVectorizer的fit_transform需要一次性看到整个语料来计算 IDF,所以它不支持流式更新。如果语料大到内存放不下,要么分批做词频统计再合并,要么改用在线学习方案。普通业务场景里,先把语料控制在内存能承受的范围,比什么都重要。
我自己做文本关键词项目的习惯是,无论后面是不是要上更复杂的模型,都会先跑一版最朴素的 TF-IDF,把 Top 10 关键词打印出来人工扫一遍。这一步能快速暴露预处理问题,比如标点没清干净、停用词漏网、语料选得不合理。TF-IDF 不解决语义问题,它解决的是“哪些词能让文档彼此区分”的问题。等基础版本稳定了,再考虑加词形还原、短语识别,或者用 BERT 这类语义模型做重排序。希望这篇里的代码和踩坑记录能帮你少折腾几个晚上。