2、TF-IDF算法:词频(TF)计算、逆文档频率(IDF)计算、TF-IDF公式详解、代码实现
2.1 先聊聊TF-IDF到底在干嘛
TF-IDF,说白了就是给每个词打个分。
你想想看,搜索引擎拿到你的查询词,比如「苹果手机」,它得知道哪些文档跟这个词最相关。最简单的想法是:哪个文档里「苹果」和「手机」出现次数多,哪个就更相关。但问题来了——「的」、「是」、「在」这些词几乎每篇文档都有,它们能算数吗?
嗯,这就是TF-IDF要解决的核心问题。
我个人习惯把TF-IDF拆成两半来理解:
- TF(词频):这个词在当前文档里重不重要?
- IDF(逆文档频率):这个词在整个文档集合里有没有区分度?
两者一乘,就是这个词对这篇文档的「代表性」有多强。
核心思想:一个词在单篇文档中出现次数越多,同时在整个文档集合中出现次数越少,它就越能代表这篇文档的主题。
2.2 词频(TF)计算
TF的计算方式有好几种,我挑最常用的三种来说。
2.2.1 原始词频
最简单粗暴的方式:
TF(t, d) = 词t在文档d中出现的次数
比如文档「我喜欢苹果,也喜欢香蕉」里,「喜欢」出现了2次,TF就是2。但这样有个问题——长文档天然占便宜。一篇5000字的文章和一篇200字的文章,同样的词出现次数肯定不一样。
2.2.2 归一化词频(我推荐这种)
我在项目中遇到过这种情况:两篇文档长度差10倍,直接用原始词频排序,结果全是长文档排前面。后来我改用归一化:
TF(t, d) = 词t在文档d中出现的次数 / 文档d的总词数
这样就把长度差异抹平了。比如「喜欢」在200字文档里出现2次,TF=0.01;在5000字文档里出现10次,TF=0.002。短文档反而得分更高,这才合理。
2.2.3 对数归一化
还有一种更平滑的方式:
TF(t, d) = log(1 + 词t在文档d中出现的次数)
为什么要加log?因为词频的增长不是线性的。你想想看,一个词出现10次和出现100次,重要性差距并没有10倍那么大。log函数正好能压住这种暴增。
我的建议:做通用搜索时用归一化词频就够了。如果文档长度差异特别大(比如有的几KB,有的几MB),可以考虑对数归一化。
2.3 逆文档频率(IDF)计算
IDF解决的是另一个问题:有些词太常见了,比如「的」、「是」、「在」,它们在几乎所有文档里都出现。这些词对区分文档没什么帮助。
公式长这样:
IDF(t) = log(总文档数N / 包含词t的文档数df(t))
我来拆解一下:
- N:文档集合的总数
- df(t):包含词t的文档数量
如果一个词在所有文档里都出现,df(t)=N,IDF=log(1)=0。这个词的权重直接归零——完美!
如果一个词只在极少数文档里出现,df(t)很小,IDF就很大。比如「Transformer」这个词,在AI领域的文档里常见,但在新闻文档里很少见,它的IDF值就会很高。
曾经踩过的坑:我曾经在处理一个百万级文档集时,发现有些生僻词df(t)=0,导致IDF公式里分母为0。后来我加了一个平滑项:IDF(t) = log((N+1)/(df(t)+1)) + 1。这样既避免了除零,又保证了IDF始终为正。
2.4 TF-IDF公式详解
把TF和IDF乘起来,就是TF-IDF:
TF-IDF(t, d) = TF(t, d) × IDF(t)
这个值越大,说明词t对文档d越重要,同时在整个文档集合里越有区分度。
举个例子你就明白了:
| 文档 | 「苹果」出现次数 | 总词数 | TF | IDF(假设1000篇文档中50篇含「苹果」) | TF-IDF |
|---|---|---|---|---|---|
| 水果介绍 | 10 | 200 | 0.05 | log(1000/50)=3.0 | 0.15 |
| 科技新闻 | 3 | 300 | 0.01 | log(1000/50)=3.0 | 0.03 |
| 通用文档 | 1 | 500 | 0.002 | log(1000/50)=3.0 | 0.006 |
你看,「苹果」在水果文档里的TF-IDF值最高,说明它最能代表这篇文档的主题。科技新闻里虽然也有「苹果」,但出现频率低,得分就低。
关键点:TF-IDF不是万能的。它假设词之间相互独立,不考虑词序和语义。说白了,它就是个基于统计的「词袋模型」。但对于很多搜索场景,它已经够用了。
2.5 代码实现
理论说完了,咱们直接上代码。我习惯用Python实现,因为简洁明了。
2.5.1 从零实现TF-IDF
import math
from collections import Counter
class TFIDF:
def __init__(self, documents):
self.documents = documents
self.N = len(documents)
self.df = {} # 文档频率
self.idf = {} # 逆文档频率
self._compute_df()
self._compute_idf()
def _compute_df(self):
"""计算每个词的文档频率"""
for doc in self.documents:
words = set(doc.split()) # 去重,只统计是否出现
for word in words:
self.df[word] = self.df.get(word, 0) + 1
def _compute_idf(self):
"""计算每个词的IDF值"""
for word, df_value in self.df.items():
# 加1平滑,避免除零
self.idf[word] = math.log((self.N + 1) / (df_value + 1)) + 1
def compute_tf(self, doc):
"""计算文档的词频"""
words = doc.split()
total_words = len(words)
tf = {}
for word, count in Counter(words).items():
tf[word] = count / total_words # 归一化词频
return tf
def compute_tfidf(self, doc):
"""计算文档的TF-IDF向量"""
tf = self.compute_tf(doc)
tfidf = {}
for word, tf_value in tf.items():
if word in self.idf:
tfidf[word] = tf_value * self.idf[word]
else:
tfidf[word] = 0 # 未出现过的词,IDF为0
return tfidf
# 使用示例
docs = [
"苹果 是 一种 水果",
"苹果 公司 发布 了 新 手机",
"香蕉 和 苹果 都 是 水果"
]
model = TFIDF(docs)
for i, doc in enumerate(docs):
result = model.compute_tfidf(doc)
print(f"文档{i+1}的TF-IDF: {result}")
2.5.2 用scikit-learn实现(生产环境推荐)
from sklearn.feature_extraction.text import TfidfVectorizer
docs = [
"苹果 是 一种 水果",
"苹果 公司 发布 了 新 手机",
"香蕉 和 苹果 都 是 水果"
]
# 创建TF-IDF向量化器
vectorizer = TfidfVectorizer()
# 拟合数据并转换
tfidf_matrix = vectorizer.fit_transform(docs)
# 查看结果
print("特征词:", vectorizer.get_feature_names_out())
print("TF-IDF矩阵:\n", tfidf_matrix.toarray())
我的经验:生产环境里我一般直接用scikit-learn的TfidfVectorizer。它内置了平滑、归一化、停用词过滤等功能,比自己手写稳定得多。但理解底层原理很重要——出了问题你能快速定位。
2.6 TF-IDF的局限性
说实话,TF-IDF在2000年代很火,但现在很多场景已经被更先进的算法取代了。我总结几个明显的短板:
- 忽略语义:「苹果」和「iPhone」在语义上相关,但TF-IDF认为它们是两个完全独立的词
- 不考虑词序:「苹果手机」和「手机苹果」在TF-IDF眼里是一样的
- 对长文档有偏:虽然归一化缓解了这个问题,但长文档包含更多词,整体得分还是会偏高
不过话说回来,TF-IDF简单、高效、可解释性强。很多中小型搜索系统,或者作为基线模型,它依然是个不错的选择。我至今还在用TF-IDF做关键词提取和文本相似度计算的快速原型。
一句话总结:TF-IDF用词频和逆文档频率的乘积,衡量一个词对文档的重要性。它不完美,但足够实用。