版权声明:署名,允许他人基于本文进行创作,且必须基于与原先许可协议相同的许可协议分发本文 (Creative Commons)
- 文中代码见 GitHub Gist 或者使用 nbviewer 查看
- 本文同步发表在sklearn 如何计算 TFIDF · Lee’s Space Station
什么是 TFIDF
简单来说,在一个文档集中,TFIDF 反映了一个词在一篇文档中的重要程度,或者说这个词在这篇文档中具有多大的「标志性」。我们可以用其作为每个词的权重进而通过计算余弦相似度来比较两篇文档的相似性。
TFIDF 是由 TF 和 IDF 的乘积得到的:
tfidf(t,d,D)=tf(t,d)⋅idf(t,D)text{tfidf}(t, d, D) = text{tf}(t, d) cdot text{idf}(t, D)tfidf(t,d,D)=tf(t,d)⋅idf(t,D)
其中,ttt 表示词项,d∈Dd in Dd∈D 表示文档,DDD 表示所有 ddd 组成的文档集。这其中 tf(t,d)text{tf}(t, d)tf(t,d) 和 idf(t,D)text{idf}(t, D)idf(t,D) 各自都有多种不同的计算方式,下面分别来说下。
tf(t,d)text{tf}(t, d)tf(t,d)
tf 指的是 term frequency,即一个词在一篇文档中出现的次数,最原始的计算方式就是直接统计词项 ttt 在文档 ddd 中出现的次数,我们将其记为 ft,df_{t, d}ft,d。除此之外,还有其他计算方式:
- 二值:如果词项 ttt 在文档 ddd 中出现,则为 1,否则为 0。此时 tf(t,d)text{tf}(t,d)tf(t,d) 的取值范围为 {0,1}{0,1}{0,1}
- 词项频率:即词项 ttt 的频数(次数)除以文档 ddd 的总词数,此时 tf(t,d)text{tf}(t,d)tf(t,d) 的取值范围为 [0,1][0,1][0,1]
tf(t,d)=ft,dΣt′∈dft,dtext{tf}(t, d) = dfrac{f_{t, d}}{Sigma_{t' in d}f_{t, d}}tf(t,d)=Σt′∈dft,dft,d
- 对数化(log normalization):此时 tf(t,d)text{tf}(t,d)tf(t,d) 的取值范围为 [0,∞][0,infty][0,∞]
tf(t,d)=log(1 ft,d)text{tf}(t, d) = log(1 f_{t,d}) tf(t,d)=log(1 ft,d)
- 双重标准化 0.5(double normalization 0.5):就是让原本的 tf(t,d)text{tf}(t,d)tf(t,d) 除以文档 ddd 中词频最高的词项的频数,这样做是为了避免偏向长文档。此时 tf(t,d)text{tf}(t,d)tf(t,d) 的取值范围为 [0,1][0,1][0,1]
tf(t,d)=0.5 0.5⋅ft,dmax{t′∈d}ft′,dtext{tf}(t, d) = 0.5 0.5 cdot dfrac{f_{t,d}}{max_{{t'in d}}f_{t',d}}tf(t,d)=0.5 0.5⋅max{t′∈d}ft′,dft,d
- 双重标准化 K(double normalization K):就是将上面方法中的 0.50.50.5 换成更为一般的 KKK。此时 tf(t,d)text{tf}(t,d)tf(t,d) 的取值范围为 [0,1][0,1][0,1]
tf(t,d)=K (1−K)⋅ft,dmax{t′∈d}ft′,dtext{tf}(t, d) = K (1-K) cdot dfrac{f_{t,d}}{max_{{t'in d}}f_{t',d}}tf(t,d)=K (1−K)⋅max{t′∈d}ft′,dft,d
idf(t,D)text{idf}(t, D)idf(t,D)
idf 指的是 inverse document frequency,即逆文档频率,衡量一个词项能提供多少信息,如它在文档集 DDD 中比较普遍还是比较少见。一般来说,是由文档集 DDD 中的文档数 NNN,除以包含词项 ttt 的文档数 ntn_tnt,然后再取对数得到:
idf(t,D)=logNnttext{idf}(t, D) = logdfrac{N}{n_t}idf(t,D)=logntN
其中 nt=∣{d∈D:t∈d}∣n_t = |{d in D:t in d}|nt=∣{d∈D:t∈d}∣。此时取值范围为 [0,∞)[0, infty)[0,∞)
除此之外,还有其他计算方式:
- 一元化(unary):即恒为 1,这也就意味着所有词项都不能提供有效信息
- 平滑的逆文档频率(inverse document frequency smooth):这是为了避免由于词项 ttt 没有出现在文档集中而发生的除零错误。此时 idf(t,D)text{idf}(t,D)idf(t,D) 的取值范围为 [logN,1][log N,1][logN,1]
idf(t,D)=logN1 nttext{idf}(t, D) = logdfrac{N}{1 n_t}idf(t,D)=log1 ntN
- inverse document frequency max(这个中文不太好翻 ?):对于文档 ddd 中的词项 t′t't′,逐个计算他们的 nt′n_{t'}nt′,并选其中的最大值来替换 NNN
idf(t,D)=logmax{t′∈d}nt′1 nttext{idf}(t, D) = logdfrac{max_{{t' in d}}n_{t'}}{1 n_t}idf(t,D)=log1 ntmax{t′∈d}nt′
- 概率逆文档频率(probabilistic inverse document frequency):还是对 NNN 替换,这次是替换为 N−ntN-n_tN−nt
idf(t,D)=logN−ntnttext{idf}(t, D) = logdfrac{N-n_t}{n_t}idf(t,D)=logntN−nt
sklearn 中如何计算
sklearn 中计算 tfidf 的函数是 TfidfTransformer
和 TfidfVectorizer
,严格来说后者 = CountVectorizer
TfidfTransformer
。TfidfTransformer
和 TfidfVectorizer
有一些共同的参数,这些参数的不同影响了 tfidf 的计算方式:
norm
:归一化,l1
、l2
(默认值)或者None
。l1
是向量中每个值除以所有值的绝对值的和()1-范数,l2
是向量中每个值除以所有值的平方开根号(2-范数),即对于l1
: xi=xi∣∣x∣∣1=xi∑j∣xj∣ x_i = dfrac{x_i}{||pmb x||_1} = dfrac{x_i}{sum_j |x_j|} xi=∣∣xxx∣∣1xi=∑j∣xj∣xi 对于l2
: xi=xi∣∣x∣∣2=xi∑jxj2 x_i = dfrac{x_i}{||pmb x||_2} = dfrac{x_i}{sqrt{sum_j x^2_j}} xi=∣∣xxx∣∣2xi=∑jxj2xiuse_idf
:bool
,默认True
,是否使用 idfsmooth_idf
:bool
,默认True
,是否平滑 idf,默认分子和分母 都 1,和上述任何一种都不一样,防止除零错误sublinear_tf
:bool
,默认False
,是否对 tf 使用 sublinear,即使用1 log(tf)
来替换原始的 tf
所以,默认参数下(norm='l2', use_idf=True, smooth_idf=True, sublinear_tf=False
),sklearn 是这么计算 tfidf 的:
tfidf(t,d,D)=tf(t,d)⋅idf(t,D)=tf(t,d)⋅(log1 N1 nt 1) begin{aligned} text{tfidf}(t, d, D) &= text{tf}(t, d) cdot text{idf}(t, D) \ &= text{tf}(t, d) cdot left(log{dfrac{1 N}{1 n_t}} 1right) end{aligned} tfidf(t,d,D)=tf(t,d)⋅idf(t,D)=tf(t,d)⋅(log1 nt1 N 1)
例子
手算
我们以如下文档集 DDD 为例,列表中每个元素是一篇文档,共有 N=4N=4N=4 篇文档,使用 jieba 分好词:
代码语言:javascript复制documents = [
"低头亲吻我的左手", # 文档 1
"换取被宽恕的承诺", # 文档 2
"老旧管风琴在角落", # 文档 3
"一直一直一直伴奏", # 文档 4
]
documents = [" ".join(jieba.cut(item)) for item in documents]
# ['低头 亲吻 我 的 左手',
# '换取 被 宽恕 的 承诺',
# '老旧 管风琴 在 角落',
# '一直 一直 一直 伴奏']
我们的词汇表如下,顺序无关:
代码语言:javascript复制一直 亲吻 伴奏 低头 在 宽恕 左手 我 承诺 换取 的 管风琴 老旧 被 角落
现在我们可以首先计算所有词的 idf,以第一个词 一直
为例:
这里的 logloglog 为自然对数,eee 为底。
idf(一直,D)=log1 N1 nt 1=log1 41 1 1=log52 1=1.916290731874155 begin{aligned} idf(一直, D) &= log{dfrac{1 N}{1 n_t}} 1 \ &= log{dfrac{1 4}{1 1}} 1 \ &= log{dfrac{5}{2}} 1 \ &= 1.916290731874155 end{aligned} idf(一直,D)=log1 nt1 N 1=log1 11 4 1=log25 1=1.916290731874155
其实除了 的
,其他所有词的 idf 都是 1.9162907318741551.9162907318741551.916290731874155,因为都只出现在一篇文档里。
以第一个词 一直
为例,来计算其 tfidf 值,按照上述 sklearn 的默认参数。其在前三篇文档中都未出现,即 tf(一直,文档1/2/3)=0text{tf}(一直, 文档1/2/3) = 0tf(一直,文档1/2/3)=0,那么 tfidf(一直,文档1/2/3,D)=0text{tfidf}(一直, 文档1/2/3, D) = 0tfidf(一直,文档1/2/3,D)=0。
最后一篇文档中,其出现了 3 次,则 tf(一直,文档4)=3text{tf}(一直, 文档4) = 3tf(一直,文档4)=3,tfidf(一直,文档4,D)=3×1.916290731874155=5.748872195622465text{tfidf}(一直, 文档4, D) = 3 times 1.916290731874155 = 5.748872195622465tfidf(一直,文档4,D)=3×1.916290731874155=5.748872195622465。最后一篇剩下的词为 伴奏
,同理可计算其 tfidf 值为 1.9162907318741551.9162907318741551.916290731874155,那么该文档的 tfidf 向量为
(5.748872195622465,0,1.916290731874155,0,0,0,0,0,0,0,0,0,0,0,0)(5.748872195622465, 0, 1.916290731874155, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)(5.748872195622465,0,1.916290731874155,0,0,0,0,0,0,0,0,0,0,0,0)
再经过2-范数归一化,得到
(0.9486833,0,0.31622777,0,0,0,0,0,0,0,0,0,0,0,0)(0.9486833, 0, 0.31622777, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)(0.9486833,0,0.31622777,0,0,0,0,0,0,0,0,0,0,0,0)
这就是文档 4 最终的 tfidf 向量了。
使用 sklearn 计算
代码如下:
代码语言:javascript复制默认情况下 sklearn 会莫名其妙地去除掉一些停用词,即使
stop_words=None
,详细讨论参见 CountVectorizer can’t remain stop words in Chinese · Issue #10756 · scikit-learn/scikit-learn
import jieba
from sklearn.feature_extraction.text import TfidfTransformer, TfidfVectorizer, CountVectorizer
documents = [
"低头亲吻我的左手",
"换取被宽恕的承诺",
"老旧管风琴在角落",
"一直一直一直伴奏",
]
documents = [" ".join(jieba.cut(item)) for item in documents]
# 默认情况下 sklearn 会莫名其妙地去除掉一些停用词,即使 stop_words=None
# 详细讨论参见 https://github.com/scikit-learn/scikit-learn/issues/10756
vectorizer = TfidfVectorizer(token_pattern=r'(?u)bw b')
X = vectorizer.fit_transform(documents)
# 词汇表
print(' '.join(vectorizer.get_feature_names()))
# '一直 亲吻 伴奏 低头 在 宽恕 左手 我 承诺 换取 的 管风琴 老旧 被 角落'
# idf
print(vectorizer.idf_)
# array([1.91629073, 1.91629073, 1.91629073, 1.91629073, 1.91629073,
# 1.91629073, 1.91629073, 1.91629073, 1.91629073, 1.91629073,
# 1.51082562, 1.91629073, 1.91629073, 1.91629073, 1.91629073])
# tfidf
print(X.toarray())
# array([[0. , 0.46516193, 0. , 0.46516193, 0. ,
# 0. , 0.46516193, 0.46516193, 0. , 0. ,
# 0.36673901, 0. , 0. , 0. , 0. ],
# [0. , 0. , 0. , 0. , 0. ,
# 0.46516193, 0. , 0. , 0.46516193, 0.46516193,
# 0.36673901, 0. , 0. , 0.46516193, 0. ],
# [0. , 0. , 0. , 0. , 0.5 ,
# 0. , 0. , 0. , 0. , 0. ,
# 0. , 0.5 , 0.5 , 0. , 0.5 ],
# [0.9486833 , 0. , 0.31622777, 0. , 0. ,
# 0. , 0. , 0. , 0. , 0. ,
# 0. , 0. , 0. , 0. , 0. ]])
可以看到和我们手算的一样。
Reference
- tf–idf - Wikipedia
- Tf-idf :: A Single-Page Tutorial - Information Retrieval and Text Mining
- 5.2. Feature extraction — scikit-learn 0.21.3 documentation
- CountVectorizer can’t remain stop words in Chinese · Issue #10756 · scikit-learn/scikit-learn