约 30 分钟 · 需要第1章的词袋与余弦

读完你能: 分清 TF 与 DF;手算 TF-IDF 和查询余弦;解释常用库的结果为什么和手算对不上;计算 BM25,并说明长度参数和词频参数各起什么作用。

1. 出现得多,不代表有区分力

在一篇讲宠物的文章里,cat 很重要;可如果语料里每一篇都有 cat,它就没法帮我们区分文档。TF-IDF 把两个因素乘在一起:这个词在本文里有多突出,以及在整个语料里有多少见。

tfidf⁡(t,d)=tf⁡(t,d)idf⁡(t).\operatorname{tfidf}(t,d)=\operatorname{tf}(t,d)\operatorname{idf}(t).

TF 有好几种取法:原始词频、除以文档长度后的相对频率、只记是否出现的 0/1,或者 1+ln⁡f1+\ln f(只对 f>0f>0 使用)。用哪一种都可以,但要写清楚。

DF 是包含这个词的文档篇数。一个词在同一篇里出现 20 次,DF 也只加 1。

2. 三种常见的 IDF,不能混着比

记文档总数为 NN,包含词 tt 的文档数为 dftdf_t:

约定 公式 每篇都出现的词得到的权重
不平滑 ln⁡(N/dft)\ln(N/df_t) 0
不平滑,再加 1 ln⁡(N/dft)+1\ln(N/df_t)+1 1
一种常用的平滑形式 ln⁡((N+1)/(dft+1))+1\ln((N+1)/(df_t+1))+1 1

下面手算时用第一种:自然对数,不做向量归一化。scikit-learn 的 TfidfVectorizer 默认用最后一种,并且对每个文档向量做 L2 归一化;即使关掉平滑,末尾那个 +1+1 也还在,所以它的数值不能直接拿来和第一种比。具体约定见 scikit-learn 特征提取文档。

3. 手算一个微型语料

仍然用第1章的三篇文档:D1 cat eats fish,D2 cat likes milk,D3 dog eats fish。N=3N=3。

词 cat eats fish dog likes milk
DF 2 2 2 1 1 1
IDF 0.4055 0.4055 0.4055 1.0986 1.0986 1.0986

其中 ln⁡(3/2)≈0.4055\ln(3/2)\approx0.4055,ln⁡3≈1.0986\ln 3\approx1.0986。

查询是 cat fish。按 cat, dog, eats, fish, likes, milk 的顺序写出向量:

q=(a,0,0,a,0,0),d1=(a,0,a,a,0,0),a=ln⁡(3/2).q=(a,0,0,a,0,0),\qquad d_1=(a,0,a,a,0,0),\qquad a=\ln(3/2).

于是

cos⁡(q,d1)=2a2(2a)(3a)=2/3≈0.8165.\cos(q,d_1)=\frac{2a^2}{(\sqrt2a)(\sqrt3a)}=\sqrt{2/3}\approx0.8165.

D2 只命中 cat,而且它的 likes、milk 都是稀有词、权重大,把向量的模拉长了;D3 只命中 fish,它独有的 dog 同样是稀有词,但另一个词 eats 权重较小。结果:

文档 与查询的余弦
D1 0.8165
D2 0.1786
D3 0.2314

排序是 D1、D3、D2。D2 和 D3 都只命中一个查询词,D3 排在前面,只是因为它多出来的权重少一些。这个分数不是“相关的概率”,也不说明 D3 比 D2 更“对”,它只描述在这种表示下向量之间的几何关系。

4. 可复现的计算

import math
from collections import Counter

docs = ["cat eats fish".split(), "cat likes milk".split(), "dog eats fish".split()]
vocab = sorted({w for d in docs for w in d})
idf = {w: math.log(len(docs) / sum(w in d for d in docs)) for w in vocab}

def vector(words):
    counts = Counter(words)
    return [counts[w] * idf[w] for w in vocab]

def cosine(a, b):
    norm = math.sqrt(sum(x*x for x in a) * sum(x*x for x in b))
    return sum(x*y for x, y in zip(a, b)) / norm if norm else 0.0

scores = [cosine(vector(["cat", "fish"]), vector(d)) for d in docs]
assert abs(scores[0] - math.sqrt(2/3)) < 1e-12

vector 只遍历词表,所以查询里不在词表中的词会被悄悄忽略。真实系统应该把这种情况记录下来:如果查询里的词全都不在词表中,得到的是零向量,余弦为 0 只说明“没法比”,不能当作“不相关”的证据。

5. BM25:重复得越多,多出来的收益越小

在 TF-IDF 里,词频翻倍,权重也翻倍。BM25 改用一个会饱和的函数,同时显式地考虑文档长度:

score⁡(d,q)=∑t∈qIDF⁡(t)ft,d(k1+1)ft,d+k1(1−b+b∣d∣/avgdl⁡).\operatorname{score}(d,q)=\sum_{t\in q}\operatorname{IDF}(t) \frac{f_{t,d}(k_1+1)}{f_{t,d}+k_1(1-b+b\lvert d\rvert/\operatorname{avgdl})}.

这里 ft,df_{t,d} 是词 tt 在文档 dd 中出现的次数,∣d∣\lvert d\rvert 是文档长度,avgdl⁡\operatorname{avgdl} 是语料的平均文档长度。求和针对查询里的每个不同的词,不额外考虑这个词在查询里出现几次。不同实现在查询权重和 IDF 上会有差别;本章用一个不会出现负值的版本:

IDF⁡(t)=ln⁡(1+N−dft+0.5dft+0.5).\operatorname{IDF}(t)=\ln\left(1+\frac{N-df_t+0.5}{df_t+0.5}\right).

两个参数的作用:

  • k1>0k_1>0 控制词频增长多快饱和。ff 很大时,词频因子趋近上限 k1+1k_1+1。
  • bb 控制长度归一化的力度:b=0b=0 完全不管长度,b=1b=1 按长度比例完全归一化,通常取 0≤b≤10\le b\le1。

文档里没有这个词(f=0f=0)时,它的贡献为 0。

6. 手算:长度和词频一起起作用

设平均长度为 100,k1=1.2k_1=1.2,b=0.75b=0.75。查询只有一个词,比较时 IDF 相同,只看词频因子:

文档 长度 词频 词频因子
A 50 1 2.2/[1+1.2(0.25+0.75×0.5)]=1.25712.2/[1+1.2(0.25+0.75\times0.5)]=1.2571
B 200 3 6.6/[3+1.2(0.25+0.75×2)]=1.29416.6/[3+1.2(0.25+0.75\times2)]=1.2941

B 的词频是 A 的三倍,得分却只高一点点:一方面 B 长了四倍,被长度归一化压低;另一方面词频本身也在饱和。如果令 b=0b=0 不管长度,两者的因子分别是 2.2/2.2=12.2/2.2=1 和 6.6/4.2≈1.57146.6/4.2\approx1.5714,差距就明显了。

import math

def bm25_term(tf, df, total_docs, length, avg_length, k1=1.2, b=0.75):
    if tf == 0:
        return 0.0
    idf = math.log(1 + (total_docs-df+0.5)/(df+0.5))
    norm = 1-b+b*length/avg_length
    return idf * tf*(k1+1)/(tf+k1*norm)

7. 统计量从哪里来

做分类实验时,词表、DF 和 IDF 只能在训练集(交叉验证时是训练折)上统计,不能先在全部数据上算好再划分。检索系统则不同:索引库本身就是系统可以看到的数据,IDF 就在索引库上统计。关键是想清楚,在这个任务里系统合法地能看到哪些数据。

真正的检索系统还要处理分词、短语、不同字段的权重和索引更新。BM25 只是关键词匹配的打分,它不理解同义改写,也不判断检索到的内容是否属实。

8. 小结

TF 数的是词出现的次数,DF 数的是包含它的文档篇数;TF-IDF 的具体数值取决于所用约定;余弦比较向量方向;BM25 同时考虑词的稀有程度、词频饱和和文档长度。要比较两套结果,先把分词、词表、对数底、平滑方式和归一化方式对齐。

自测

1. N=10,某个词在 10 篇文档里都出现,三种 IDF 各是多少?

不平滑:ln⁡(10/10)=0\ln(10/10)=0;不平滑再加 1:0+1=10+1=1;平滑形式:ln⁡(11/11)+1=1\ln(11/11)+1=1。三个数不同不是算错了,而是定义不同。

2. BM25 取 b=0 之后,长文档还会吃亏吗?

不会再因为“长”本身被压分,但词频饱和仍然存在。另外,如果系统还有字段权重或查询权重,也要一并考虑。