第3章 关键词表示与检索:TF-IDF、余弦与 BM25
读完你能: 分清 TF 与 DF;手算 TF-IDF 和查询余弦;解释常用库的结果为什么和手算对不上;计算 BM25,并说明长度参数和词频参数各起什么作用。
1. 出现得多,不代表有区分力
在一篇讲宠物的文章里,cat 很重要;可如果语料里每一篇都有 cat,它就没法帮我们区分文档。TF-IDF 把两个因素乘在一起:这个词在本文里有多突出,以及在整个语料里有多少见。
TF 有好几种取法:原始词频、除以文档长度后的相对频率、只记是否出现的 0/1,或者 (只对 使用)。用哪一种都可以,但要写清楚。
DF 是包含这个词的文档篇数。一个词在同一篇里出现 20 次,DF 也只加 1。
2. 三种常见的 IDF,不能混着比
记文档总数为 ,包含词 的文档数为 :
| 约定 | 公式 | 每篇都出现的词得到的权重 |
|---|---|---|
| 不平滑 | 0 | |
| 不平滑,再加 1 | 1 | |
| 一种常用的平滑形式 | 1 |
下面手算时用第一种:自然对数,不做向量归一化。scikit-learn 的 TfidfVectorizer 默认用最后一种,并且对每个文档向量做 L2 归一化;即使关掉平滑,末尾那个 也还在,所以它的数值不能直接拿来和第一种比。具体约定见 scikit-learn 特征提取文档。
3. 手算一个微型语料
仍然用第1章的三篇文档:D1 cat eats fish,D2 cat likes milk,D3 dog eats fish。。
| 词 | 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 |
其中 ,。
查询是 cat fish。按 cat, dog, eats, fish, likes, milk 的顺序写出向量:
于是
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 改用一个会饱和的函数,同时显式地考虑文档长度:
这里 是词 在文档 中出现的次数, 是文档长度, 是语料的平均文档长度。求和针对查询里的每个不同的词,不额外考虑这个词在查询里出现几次。不同实现在查询权重和 IDF 上会有差别;本章用一个不会出现负值的版本:
两个参数的作用:
- 控制词频增长多快饱和。 很大时,词频因子趋近上限 。
- 控制长度归一化的力度: 完全不管长度, 按长度比例完全归一化,通常取 。
文档里没有这个词()时,它的贡献为 0。
6. 手算:长度和词频一起起作用
设平均长度为 100,,。查询只有一个词,比较时 IDF 相同,只看词频因子:
| 文档 | 长度 | 词频 | 词频因子 |
|---|---|---|---|
| A | 50 | 1 | |
| B | 200 | 3 |
B 的词频是 A 的三倍,得分却只高一点点:一方面 B 长了四倍,被长度归一化压低;另一方面词频本身也在饱和。如果令 不管长度,两者的因子分别是 和 ,差距就明显了。
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 各是多少?
不平滑:;不平滑再加 1:;平滑形式:。三个数不同不是算错了,而是定义不同。
2. BM25 取 b=0 之后,长文档还会吃亏吗?
不会再因为“长”本身被压分,但词频饱和仍然存在。另外,如果系统还有字段权重或查询权重,也要一并考虑。
- 感谢你赐予我前进的力量

