> For the complete documentation index, see [llms.txt](https://inwt233.gitbook.io/ai-learning/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://inwt233.gitbook.io/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-02/day-011.md).

# Day 011：Bag of Words 与 TF-IDF

{% hint style="info" %}
**怎样把"哪篇文档和查询相关"变成一个可以计算的问题？"的"和"文本"在文档里都只出现一次，为什么它们的重要性不该相同？**
{% endhint %}

## 学习目标

完成本节后，你应该能够：

1. 把文档表示成词袋（Bag of Words）向量，并说清它保留了什么、丢弃了什么；
2. 区分词袋表示与 one-hot、unigram 语言模型和 Embedding；
3. 写出 TF 的三种常见变体，并解释对数饱和的动机；
4. 从文档频率推导 IDF，解释它为什么度量"术语区分度"；
5. 手算小型语料的 TF-IDF 向量，并用代码输出复核；
6. 用余弦相似度对查询排序，解释范数除法带来的长度归一化效果；
7. 识别 IDF 变体和实现细节（如 scikit-learn 的平滑与归一化）对小语料排名的影响；
8. 说明词袋方法的词汇鸿沟、稀疏性和堆词攻击等失效模式。

{% hint style="info" %}
Day 008–010 回答的是"如何给 Token 序列分配概率"。从今天起切到检索视角：不再预测下一个 Token，而是判断"文档与查询有多相关"。Day 013 的 BM25 与 Day 014 的搜索器实验都建立在今天的词袋与 IDF 之上；Day 012 会先回到语言模型线，解释交叉熵为什么能评价语言模型。
{% endhint %}

## 1. 从"预测下一个 Token"到"给文档打分"

检索任务的标准形式是：给定查询 $$q$$ 和文档集合 $$D={d\_1,\ldots,d\_N}$$，为每篇文档打一个相关度分数，按分数从高到低返回前 $$k$$ 篇。

这与语言模型是两类问题：

* 语言模型问：**这段文本出现的概率是多少**（Day 008 的 $$P(w\mid h)$$）；
* 检索问：**这篇文档和查询有多相关**——这根本不是一个概率问题。

要给"相关"建立可计算的代理，思路是：如果查询词在文档中反复出现，且这些词本身又很能区分文档，文档大概就相关。这条思路的完整数据流是：

```
文档集合 D
  → 用第 1 周固定的 Tokenizer 切分（不引入新的分词规则）
  → 统计每篇文档的词袋计数 C(t, d)
  → 从全语料统计文档频率 df(t)，计算 idf(t)
  → 组装每篇文档的 TF-IDF 向量（稀疏表示）
  → 查询按同一词表、同一 IDF 向量化
  → 计算余弦相似度并排序
```

这就是 Salton 等人 1975 年提出的**向量空间模型**（Vector Space Model）：文档和查询都变成同一空间中的向量，相关度用向量夹角衡量。

## 2. Bag of Words：保留计数，丢弃顺序

### 2.1 严格定义

设词表为 $$V$$，大小 $$|V|$$。文档 $$d$$ 的**词袋表示**是一个 $$|V|$$ 维计数向量，第 $$j$$ 维是词 $$t\_j$$ 在 $$d$$ 中出现的次数 $$C(t\_j,d)$$。

本节的示例语料沿用本周约定：文档已按词切好，直接用 Token 列表表示。

```python
corpus = [
    ["自然语言", "处理", "是", "人工智能", "的", "分支"],
    ["计算机", "视觉", "是", "人工智能", "的", "分支"],
    ["自然语言", "处理", "研究", "文本"],
    ["人工智能", "改变", "世界"],
]
```

四篇文档构成的"词—文档"矩阵（term-document matrix）是 $$12\times4$$：12 个不同的词，4 篇文档。本节各处的编号为 doc0 到 doc3。

### 2.2 词袋到底丢了什么

词袋丢弃的是**顺序**，保留的是**多重集计数**。反例：

```
我 喜欢 自然语言
自然语言 喜欢 我
```

两句话的词袋向量完全相同。任何基于词袋的方法都无法区分它们——这是词袋模型的固有边界，不是实现缺陷。

### 2.3 和相邻概念的关系

* 与 **one-hot**：one-hot 只表达"词是什么"，维度是词表，每个向量恰有一个 1；词袋表达"文档里每个词出现几次"。
* 与 **unigram 语言模型**：把词袋计数除以文档长度，就得到该文档上的 unigram 分布 $$P(t\mid d)$$。Day 008 统计的 $$C(w)$$ 就是把整个语料当一篇文档时的词袋计数。词袋是计数，概率是它的归一化版本。
* 与 **Embedding**：词袋的每一维都直接绑定一个具体的词，"自然语言"和"语言"是两个互不相关的维度；Embedding（Day 015 起）则把词映射到低维稠密空间，语义相近的词向量夹角小。词袋没有任何语义泛化能力。

{% hint style="warning" %}
词袋向量是 $$|V|$$ 维的稀疏向量：非零项个数不超过文档长度。真实系统用稀疏存储或倒排索引（Day 078）组织它，绝不该为每篇文档分配 $$|V|$$ 长的稠密数组。
{% endhint %}

## 3. TF：词在文档内出现多频繁

**TF（Term Frequency，词频）**&#x8861;量词 $$t$$ 在文档 $$d$$ 内部的频繁程度。常见三种变体：

| 变体   | 公式                | 特点          |
| ---- | ----------------- | ----------- |
| 原始计数 | tf(t, d)          | 最直接，随重复线性增长 |
| 相对频率 | tf(t, d) / len(d) | 消除文档长度影响    |
| 对数饱和 | 1 + ln(tf(t, d))  | 抑制重复堆砌      |

用 doc0（长度 6）作分母、把 tf 取 1、2、4、8，三种变体的取值是（其中 8 超过单篇真实文档的边界，仅用于观察增长趋势）：

| tf | tf/len(d) | 1+ln(tf) |
| -: | --------: | -------: |
|  1 |  0.166667 | 1.000000 |
|  2 |  0.333333 | 1.693147 |
|  4 |  0.666667 | 2.386294 |
|  8 |  1.333333 | 3.079442 |

原始计数每翻一倍，权重精确翻倍：出现 8 次的词权重是出现 1 次的 8 倍。但直觉上，一个词重复 8 次带来的"额外相关证据"远不到 8 倍——第 2 次出现的信息量已经不大，第 8 次几乎不增加信息。这就是**饱和**（saturation）。

对数变体 $$1+\ln(\mathrm{tf})$$ 让权重随重复次数**次线性**增长：8 倍的重复只换来约 3 倍的权重。它也是 BM25 饱和项的思想前身（Day 013 展开）。相对频率变体则完全不做饱和，只做长度归一化，两者解决的问题不同。

{% hint style="info" %}
早年的检索系统常先手工删除停用词（stopwords，如"的、是、the"）。TF-IDF 的立场是让 IDF 自动压低这类词的权重。现代系统通常不再维护停用词表，但极高频词在极短文档里仍可能干扰排序，是否过滤应通过评价集验证，而不是当作固定常识。
{% endhint %}

## 4. IDF：词在语料中多稀有

### 4.1 文档频率与术语区分度

**df（document frequency，文档频率）**$$\mathrm{df}(t)$$ 是语料中**包含**词 $$t$$ 的文档篇数（注意不是出现次数：一篇文档里出现 8 次也只贡献 1）。

Sparck Jones 1972 年提出的直觉是：一个词出现在几乎每篇文档里，它就**区分不了**文档；只出现在少数文档里的词才是好的"指纹"。IDF（Inverse Document Frequency，逆文档频率）的经典定义：

$$
\operatorname{idf}(t)=\ln\frac{N}{\operatorname{df}(t)}
$$

其中 $$N$$ 是语料文档总数，对数底任意但必须全程一致（本文用自然对数）。在本节语料中：

| 词             | df | ln(N/df) | 说明             |
| ------------- | -: | -------: | -------------- |
| 世界、文本、研究      |  1 | 1.386294 | 只在 1 篇出现，区分度最高 |
| 自然语言、处理、的     |  2 | 0.693147 | 中等             |
| 人工智能          |  3 | 0.287682 | 三篇都有，区分度低      |
| 假设某词在全部 4 篇出现 |  4 | 0.000000 | 完全无区分度，权重归零    |

### 4.2 两个边界

**df 等于 N**：经典 IDF 给出 $$\ln 1=0$$，该词权重恰好归零。这是设计而非 bug——每篇都有的词对排序毫无贡献（前提是使用原始计数 TF 且做了归一化打分，见第 8 节的反例）。

**df 等于 0**：查询词不在任何文档中出现时，$$\ln(N/0)$$ 未定义。这是检索版的 OOV（呼应 Day 003 与 Day 009）：必须显式决定跳过、报错还是用平滑公式兜底，不能让除零替你做决定。

### 4.3 常见 IDF 变体

不同实现使用的 IDF 公式并不相同：

| 变体                  | 公式                      | 行为                                       |
| ------------------- | ----------------------- | ---------------------------------------- |
| 经典                  | ln(N/df)                | df=N 时为 0；df=0 未定义                       |
| 概率式                 | ln((N-df+0.5)/(df+0.5)) | 信息检索推导的产物；BM25 使用，df 大时可为负，实现中通常截断为一个小正数 |
| 平滑（scikit-learn 默认） | ln((1+N)/(1+df)) + 1    | 任何词权重都不低于 1，df=0 也有定义                    |

在本例语料（$$N=4$$）中，概率式对 $$\mathrm{df}=3$$ 的"人工智能"给出 $$\ln(1.5/3.5)\approx-0.847298$$，是负值——这正是 BM25 实现要截断的原因。平滑变体对 $$\mathrm{df}=3$$ 给出 $$\ln(5/4)+1\approx1.223144$$，对未见词（$$\mathrm{df}=0$$）给出 $$\ln 5+1\approx2.609438$$。

{% hint style="warning" %}
"TF-IDF"不是单一公式，而是一族约定。第 8 节会展示：在本节的小语料上，仅把经典 IDF 换成平滑变体，同一查询的排名就会反转。任何检索实验报告都必须写清使用了哪个变体。
{% endhint %}

## 5. TF-IDF 向量：局部证据乘全局区分度

把两个信号相乘，得到词 $$t$$ 在文档 $$d$$ 中的权重：

$$
w(t,d)=\operatorname{tf}(t,d)\times\operatorname{idf}(t)
$$

直觉：一个词对"这篇文档和查询相关"的证据强度，等于**它在这篇文档里的频繁程度**（局部证据）乘以**它在语料中的稀有程度**（全局区分度）。

手算 doc0（原始计数 TF、经典 IDF）：

* $$w(\text{自然语言},d\_0)=1\times\ln(4/2)=0.693147$$
* $$w(\text{人工智能},d\_0)=1\times\ln(4/3)=0.287682$$
* 其余四个词 $$\mathrm{df}=2$$，权重均为 $$0.693147$$

于是 doc0 的稀疏 TF-IDF 向量为：

```
{人工智能: 0.287682, 分支: 0.693147, 处理: 0.693147,
 是: 0.693147, 的: 0.693147, 自然语言: 0.693147}
```

注意 TF-IDF 权重**不是概率**：一篇文章所有词的权重之和没有任何等于 1 的约束，也不要求非负（概率式 IDF 变体可为负）。它只是为排序服务的分数。这与 Day 008 的条件分布 $$\sum\_w P(w\mid h)=1$$ 形成对照——两套数学服务于两个不同的问题。

## 6. 余弦相似度与查询排序

查询 $$q$$ 也用同一词表、同一 IDF 转成向量，然后对每篇文档计算：

$$
\cos(q,d)=\frac{q\cdot d}{\lVert q\rVert,\lVert d\rVert}
\=\frac{\sum\_t q\_t,d\_t}{\sqrt{\sum\_t q\_t^2}\sqrt{\sum\_t d\_t^2}}
$$

分子是匹配词的加权和：查询词不在文档中，该项为 0。除以两个范数是**长度归一化**：不除的话，词多、词频高的长文档天然点积大，占不公平的优势。

按余弦从高到低排序，就得到检索结果。排名的解释力来自 TF-IDF 的两个信号和范数的稀释效应——第 7 节用真实输出逐条解读。

## 7. Python 实现：从计数到排序

完整实现只用标准库。先建词表和词袋计数：

```python
from collections import Counter
from math import log, sqrt

corpus = [
    ["自然语言", "处理", "是", "人工智能", "的", "分支"],
    ["计算机", "视觉", "是", "人工智能", "的", "分支"],
    ["自然语言", "处理", "研究", "文本"],
    ["人工智能", "改变", "世界"],
]

vocab = sorted({token for doc in corpus for token in doc})
print(f"N = {len(corpus)} 篇文档，词表大小 |V| = {len(vocab)}")

# 词袋向量就是词表维度上的计数向量；用 Counter 只保存非零项。
bow_doc0 = Counter(corpus[0])
print("doc0 =", " ".join(corpus[0]))
print("BoW(doc0) =", dict(sorted(bow_doc0.items())))
```

输出：

```
N = 4 篇文档，词表大小 |V| = 12
doc0 = 自然语言 处理 是 人工智能 的 分支
BoW(doc0) = {'人工智能': 1, '分支': 1, '处理': 1, '是': 1, '的': 1, '自然语言': 1}
```

### 7.1 统计 df 并计算两种 IDF

```python
# set(doc) 保证每篇文档对每个词的文档频率只贡献 1。
doc_freq = Counter(token for doc in corpus for token in set(doc))
N = len(corpus)

def idf_classic(token):
    # 经典 IDF：df 越小权重越大；df = N 时权重为 0。
    return log(N / doc_freq[token])

def idf_smooth(token):
    # scikit-learn 默认的平滑变体：任何词的权重都不低于 1。
    return log((1 + N) / (1 + doc_freq[token])) + 1

print(f"{'词':<6}{'df':>4}{'ln(N/df)':>12}{'平滑变体':>12}")
for token in vocab:
    print(
        f"{token:<6}"
        f"{doc_freq[token]:>4}"
        f"{idf_classic(token):>12.6f}"
        f"{idf_smooth(token):>12.6f}"
    )
```

输出：

```
词       df    ln(N/df)        平滑变体
世界       1    1.386294    1.916291
人工智能     3    0.287682    1.223144
分支       2    0.693147    1.510826
处理       2    0.693147    1.510826
改变       1    1.386294    1.916291
文本       1    1.386294    1.916291
是        2    0.693147    1.510826
的        2    0.693147    1.510826
研究       1    1.386294    1.916291
自然语言     2    0.693147    1.510826
视觉       1    1.386294    1.916291
计算机      1    1.386294    1.916291
```

与第 4 节的手算逐项一致。"人工智能"出现在三篇文档中，经典权重只有 0.287682，不足稀有词权重的四分之一——尽管它 tf=1、和"世界"一样只出现一次。**TF 相同时，IDF 单独决定了权重差异**，这正是"的"和"文本"不该等权的答案。

### 7.2 组装 TF-IDF 向量

```python
def tfidf_vector(tokens):
    """原始词频乘经典 IDF；只返回出现过的词（稀疏表示）。"""
    counts = Counter(tokens)
    return {token: count * idf_classic(token) for token, count in counts.items()}

doc_vectors = [tfidf_vector(doc) for doc in corpus]
for i, vec in enumerate(doc_vectors):
    items = ", ".join(f"{t}: {w:.6f}" for t, w in sorted(vec.items()))
    print(f"tfidf(doc{i}) = {{{items}}}")
```

输出：

```
tfidf(doc0) = {人工智能: 0.287682, 分支: 0.693147, 处理: 0.693147, 是: 0.693147, 的: 0.693147, 自然语言: 0.693147}
tfidf(doc1) = {人工智能: 0.287682, 分支: 0.693147, 是: 0.693147, 的: 0.693147, 视觉: 1.386294, 计算机: 1.386294}
tfidf(doc2) = {处理: 0.693147, 文本: 1.386294, 研究: 1.386294, 自然语言: 0.693147}
tfidf(doc3) = {世界: 1.386294, 人工智能: 0.287682, 改变: 1.386294}
```

### 7.3 余弦打分与查询排序

```python
def cosine(vec_a, vec_b):
    # 分子只累加两个向量共有的词；稀疏表示下不匹配项自动跳过。
    dot = sum(vec_a[t] * vec_b.get(t, 0.0) for t in vec_a)
    norm_a = sqrt(sum(w * w for w in vec_a.values()))
    norm_b = sqrt(sum(w * w for w in vec_b.values()))
    if norm_a == 0 or norm_b == 0:
        return 0.0  # 空向量或全部词都不匹配时约定得分为 0。
    return dot / (norm_a * norm_b)

def search(query, doc_vectors):
    # 检索阶段同样需要 OOV 策略：词表外的查询词没有 IDF。
    for token in query:
        if token not in doc_freq:
            raise ValueError(f"查询词 {token!r} 不在词表中")
    query_vec = tfidf_vector(query)
    scored = sorted(
        ((cosine(query_vec, vec), i) for i, vec in enumerate(doc_vectors)),
        reverse=True,
    )
    return query_vec, scored


for query in (["自然语言", "处理"], ["人工智能"]):
    query_vec, ranked = search(query, doc_vectors)
    print(f"\nquery = {' '.join(query)}")
    for score, i in ranked:
        print(f"  cos(q, doc{i}) = {score:.6f}")
```

输出：

```
query = 自然语言 处理
  cos(q, doc0) = 0.621835
  cos(q, doc2) = 0.447214
  cos(q, doc3) = 0.000000
  cos(q, doc1) = 0.000000

query = 人工智能
  cos(q, doc0) = 0.182493
  cos(q, doc3) = 0.145183
  cos(q, doc1) = 0.124170
  cos(q, doc2) = 0.000000
```

### 7.4 逐条解读第一个查询

查询"自然语言 处理"只命中 doc0 和 doc2。看原始点积和范数：

```python
query_vec, _ = search(["自然语言", "处理"], doc_vectors)
for i, vec in enumerate(doc_vectors):
    dot = sum(query_vec[t] * vec.get(t, 0.0) for t in query_vec)
    norm = sqrt(sum(w * w for w in vec.values()))
    print(f"dot(q, doc{i}) = {dot:.6f}   |doc{i}| = {norm:.6f}")
```

输出：

```
dot(q, doc0) = 0.960906   |doc0| = 1.576397
dot(q, doc1) = 0.000000   |doc1| = 2.316839
dot(q, doc2) = 0.960906   |doc2| = 2.191924
dot(q, doc3) = 0.000000   |doc3| = 1.981511
```

* **点积完全并列**：两篇文档都恰好各含查询词一次，分子都是 $$0.693147^2\times2=0.960906$$。
* **余弦分出胜负**：doc2 的范数（2.191924）比 doc0（1.576397）大，因为它还含有"研究、文本"两个高 IDF 词。这些词与查询无关，却抬大了分母，**稀释**了匹配分数。

所以余弦相似度隐含一个假设：一篇文档里与查询无关的强特征越多，这次匹配就越"不专一"。这通常是合理偏好，但它也意味着"顺带讨论了许多别的重要话题"的文档会被压低——这是排序调优时的真实权衡。

### 7.5 逐条解读第二个查询

查询"人工智能"命中三篇文档，分子都是 $$0.287682^2$$，排名完全由范数决定：$$1.576397<1.981511<2.316839$$，于是 doc0 > doc3 > doc1。

doc1 比 doc3 低的原因值得注意：两者都含"人工智能"，但 doc1 的其余词（计算机、视觉，IDF 均为 1.386294）比 doc0 的其余词（自然语言、处理等，IDF 均为 0.693147）稀有得多，范数更大。**匹配证据相同时，文档其余部分越"重"，得分越低。**

### 7.6 OOV 查询的显式失败

```python
search(["大模型", "检索"], doc_vectors)
```

抛出：

```
ValueError: 查询词 '大模型' 不在词表中
```

与 Day 009 的语言模型一致：这是有意为之的显式失败。生产检索系统通常改为更宽容的策略（跳过未知词但记录日志、给未知词一个平滑 IDF），但那必须是写明的决策，而不是偶然行为。

## 8. 两个反例：细节改变排名

### 8.1 去掉 IDF，排名反转

用纯计数向量（不加权）重算同一个查询的余弦：

```python
count_vectors = [Counter(doc) for doc in corpus]
query_counts = Counter(["自然语言", "处理"])
for i, vec in enumerate(count_vectors):
    print(f"cos_counts(q, doc{i}) = {cosine(query_counts, vec):.6f}")
```

输出：

```
cos_counts(q, doc0) = 0.577350
cos_counts(q, doc1) = 0.000000
cos_counts(q, doc2) = 0.707107
cos_counts(q, doc3) = 0.000000
```

doc2（0.707107）反而排在 doc0（0.577350）之前，与第 7 节的 TF-IDF 排名**相反**。

原因：不加权时范数只由文档长度决定（$$\sqrt6$$ 与 $$2$$），短文档 doc2 占优；加 IDF 后，doc0 里五个常见词的权重被压到 0.693147，而 doc2 里两个稀有词保持 1.386294，doc2 的范数反而超过 doc0。IDF 不仅加权了分子里的匹配项，也**重排了每篇文档的有效长度**。这说明"IDF 只是给匹配词加权"的理解是片面的。

### 8.2 换成平滑 IDF 变体，排名也反转

用 scikit-learn 1.7.0 的 `TfidfVectorizer` 复算（安装：`pip install scikit-learn`）。语料已按词切分，用 `analyzer=str.split` 避免重新分词：

```python
# 需要 scikit-learn >= 1.0；本节在 1.7.0 上验证。
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

vectorizer = TfidfVectorizer(analyzer=str.split)
X = vectorizer.fit_transform([" ".join(doc) for doc in corpus])

names = vectorizer.get_feature_names_out()
row = X.toarray()[0]
print("doc0 的 L2 归一化 TF-IDF 向量（仅非零项）：")
for j in sorted(range(len(names)), key=lambda j: -row[j]):
    if row[j] > 0:
        print(f"  {names[j]}: {row[j]:.6f}")

query = vectorizer.transform(["自然语言 处理"])
scores = cosine_similarity(query, X)[0]
print("\nquery = 自然语言 处理：")
for i, score in sorted(enumerate(scores), key=lambda pair: -pair[1]):
    print(f"  doc{i}: {score:.6f}")
```

输出：

```
doc0 的 L2 归一化 TF-IDF 向量（仅非零项）：
  分支: 0.420501
  处理: 0.420501
  是: 0.420501
  的: 0.420501
  自然语言: 0.420501
  人工智能: 0.340432

query = 自然语言 处理：
  doc2: 0.619130
  doc0: 0.594678
  doc1: 0.000000
  doc3: 0.000000
```

doc2 又回到了第一，与第 7 节经典 IDF 的排名相反。两个差异在起作用：

1. **平滑 IDF 压缩了权重比**：稀有词与常见词的 IDF 之比从 $$1.386294/0.693147=2$$ 缩到 $$1.916291/1.510826\approx1.27$$，doc2 靠稀有词撑起的范数优势被削弱；
2. **L2 行归一化**：每篇文档向量被缩放成单位长度，向量的"长度"信息不再单独参与排序。

{% hint style="warning" %}
两个反例的共同教训：在小语料上，"词袋 + TF-IDF + 余弦"的每个环节都有多种合法变体，排名结论可能完全翻转。没有一个变体在所有场景下"正确"，但**任何一个结果都必须连同它的公式、变体和归一化方式一起报告**，否则不可复现。
{% endhint %}

## 9. 边界条件与实现差异

* **语言与分词**：本节语料是已切分好的中文词。英文天然按空格切分，还要处理大小写规范化（Day 002）；中文若用字级切分，词表小但每个字的多义性更强，IDF 的"指纹"效果变弱。
* **查询向量的 IDF 来源**：查询与文档必须使用同一词表、同一 IDF（本节都从同一语料统计）。混用两套统计会让分数失去可比性。
* **L2 归一化的副作用**：scikit-learn 默认把每行缩放为单位向量，范数恒为 1；此时 IDF 为 0 的词（$$\mathrm{df}=N$$）权重为 0，从排序中自动消失。
* **饱和选项**：`TfidfVectorizer(sublinear_tf=True)` 会把 TF 换成 $$1+\ln(\mathrm{tf})$$，即第 3 节的对数变体。
* **IDF 底数**：只要分子分母一致，$$\ln$$ 与 $$\log\_2$$ 只差一个常数因子，在同一系统内不改变**排名**（但改变分数数值，跨系统比较时必须换算）。

## 10. 工程权衡与失效模式

| 方法            | 词序 | 语义泛化 | 主要代价         |
| ------------- | -- | ---- | ------------ |
| 词袋计数          | 无  | 无    | 高频词主导        |
| TF-IDF        | 无  | 无    | 词汇鸿沟、稀疏向量    |
| BM25（Day 013） | 无  | 无    | 饱和与长度参数需调优   |
| 稠密检索（Day 080） | 弱  | 有    | 依赖模型训练、向量库成本 |

词袋方法在工程上的主要失效模式：

* **词汇鸿沟**（vocabulary mismatch）：查询"自然语言"永远匹配不到只写"NLP"或"文本挖掘"的文档，因为它们是词表中不同的维度。这是词袋无法自救的边界，混合检索（Day 083）与稠密检索（Day 080）是现代的解法。
* **堆词攻击**：原始计数 TF 线性增长，文档把关键词重复 100 次就能霸榜——早期搜索引擎的经典作弊手段。对数饱和（以及 BM25）就是针对它的防御；这也是用**评价集**而非肉眼验证排序质量的原因。
* **长度偏置**：余弦只做了粗粒度的长度归一化，对"长文档恰好全面覆盖查询词"的场景仍可能过度惩罚或奖励（BM25 用显式长度参数处理，Day 013）。
* **词表静态**：词表在统计时固定，新词、新实体一律 OOV（呼应 Day 003）。生产系统需要重建词表与索引的流程。
* **效率**：朴素做法对每篇文档逐一算余弦，代价 $$O(N\cdot|V|)$$。真实系统用**倒排索引**从词直接定位文档（Day 078、Day 014），词袋思想不变，实现换底。

## 11. 常见误区

### 误区一：TF-IDF 是概率

权重之和没有归一化约束，不非负（概率式变体可为负），不可解释为"文档属于该主题的概率"。它只是排序分数。

### 误区二：IDF 高说明文档更相关

IDF 是**词的属性**，由语料统计决定，与任何一篇具体文档无关。"世界"IDF 高不代表提到"世界"的文档都相关——相关还要看查询里有没有这个词、TF 如何。

### 误区三：词袋丢掉了词的重要性

词袋丢弃的是**顺序**（句法、结构），保留的是**多重集计数**。"重要性"恰恰是 TF-IDF 在词袋之上重新赋予的。

### 误区四：余弦等于 1 就是同一篇文章

余弦等于 1 只说明词频分布方向相同。词序完全不同的两句话、甚至凑巧计数相同的两篇不同文章，都可能得到同一个词袋向量（第 2.2 节的反例）。

### 误区五：所有 TF-IDF 实现公式一致

第 8 节用真实输出证明：经典与平滑变体在小语料上给出相反排名。读任何"TF-IDF 分数"前先确认公式。

### 误区六：查询用另一套词表或 IDF

查询与文档必须共享词表和 IDF 统计，否则分数不可比，OOV 行为也会不一致。

### 误区七：把 TF-IDF 向量当语义表示

"自然语言"与"语言"是词表中两个无关的维度，余弦为 0。TF-IDF 向量不能做语义相似度推理，稠密 Embedding 才是那条线（Day 015、Day 079）。

### 误区八：在 4 篇文档的语料上比较变体优劣

本节的反转现象部分来自 $$N=4$$ 的统计噪声。真实结论应在万级以上文档、带标注相关性的评价集上验证（Day 092 的 Recall\@K、NDCG）。

## 练习与面试准备

### 30 秒口述题

请在 30 秒内回答：

> Bag of Words 和 TF-IDF 是什么？为什么要乘 IDF？主要代价是什么？

合格答案应包含：文档表示成词表维度上的计数向量（丢顺序、保留计数）；TF 是局部频率、IDF 用文档频率的倒数量化区分度，相乘让"频繁且稀有"的词主导匹配；代价是词汇鸿沟、稀疏、无语义泛化。

### 基础题 1：手算 TF-IDF

语料 $$N=5$$ 篇文档。词 $$t$$ 在文档 A（长度 10）中出现 2 次，全语料 $$\mathrm{df}(t)=1$$。分别用三种 TF 变体（原始计数、相对频率、对数饱和）乘经典 IDF，写出三个权重。

<details>

<summary>参考答案</summary>

$$\operatorname{idf}(t)=\ln5\approx1.609438$$。

* 原始计数：$$2\times1.609438\approx3.218876$$；
* 相对频率：$$0.2\times1.609438\approx0.321888$$；
* 对数饱和：$$(1+\ln2)\times1.609438\approx1.693147\times1.609438\approx2.725015$$。

三个数不可直接比较大小：变体不同，尺度不同，只能各自在自己的一致约定下使用。

</details>

### 基础题 2：解释零权重

往语料里加一篇文档后，"的"恰好出现在全部 $$N$$ 篇文档中。经典 IDF 下它的权重是多少？这是 bug 吗？换成平滑变体呢？

<details>

<summary>参考答案</summary>

经典 IDF 给 $$\ln(N/N)=0$$，"的"从打分中自动消失，这是设计特性：处处出现的词没有区分度。平滑变体给 $$\ln\frac{1+N}{1+N}+1=1$$，权重不为 0——两套变体对"常见词"的哲学不同，恰好印证报告必须写明公式。

</details>

### 进阶题 3：解释并列与反转

沿用第 7 节的输出：查询"自然语言 处理"对 doc0、doc2 的点积同为 0.960906。为什么去掉 IDF 后余弦排名从 doc0 在前变成 doc2 在前？

<details>

<summary>参考答案</summary>

点积并列时排名由范数决定。不加权时范数就是文档长度的函数（$$\sqrt6\approx2.449$$ 对 2），短文档 doc2 胜；加 IDF 后 doc0 的五个常见词权重被压半（0.693147），doc2 的两个稀有词保持 1.386294，doc2 范数反而更大（2.191924 对 1.576397），doc0 胜。IDF 同时改动了分子与"有效长度"。

</details>

### 进阶题 4：代码审查

下面的实现有什么问题？

```python
def idf(token, corpus):
    df = sum(doc.count(token) for doc in corpus)  # 出现次数求和
    return log(len(corpus) / df)
```

<details>

<summary>参考答案</summary>

`doc.count(token)` 统计的是**出现次数**而不是"是否包含"：一篇文档重复 8 次会把 df 抬大 8。文档频率应按包含该词的**文档篇数**统计，如 `sum(token in doc for doc in corpus)`。此外没有处理 df=0（除零）与 df 溢出的边界。

</details>

### 面试追问 5：什么时候用 1+ln(tf) 而不是 tf/len(d)

两者解决不同问题：$$1+\ln(\mathrm{tf})$$ 抑制重复堆砌（饱和），$$\mathrm{tf}/\mathrm{len}(d)$$ 消除文档长度差异（归一化），可以同时使用。若语料中存在刻意堆词或天然重复多的内容（评论、字幕），饱和更重要；若文档长度差异极大且未做其他长度处理，相对频率更关键。回答时最好补一句：选择应由检索评价集验证。

### 面试追问 6：查询词不在语料中怎么办

先声明这是检索侧的 OOV：词表中没有该词，任何 IDF 变体按 df=0 处理都要显式设计——经典公式未定义，应报错或跳过；平滑公式给出 $$\ln(1+N)+1$$ 的固定权重，等价于"当作最稀有的词"。再补一句工程权衡：跳过并记录日志便于发现词表过期，静默兜底会掩盖问题。

### 项目答辩题 7：TF-IDF 搜索上线后，用户查同义词召回差

例：查询"机器学习"命中很少，但语料里大量文档写的是"ML""深度学习"。如何用证据定位并改进？

答题要点：

* 构造带相关性标注的评价集（Day 092 前置），分别度量 Recall\@10、MRR，确认缺口集中在同义表达；
* 抽样失败查询做错误归因：是检索没召回（词汇鸿沟）还是召回后排序压低；
* 改进路径按成本递增：查询改写/同义词扩展（Day 088）、混合稀疏稠密检索（Day 083）、稠密向量召回（Day 080）；
* 用同一评价集做前后对比与回归，报告改进幅度，而不是展示个别成功例子。

## 延伸阅读

1. [Salton, Wong & Yang：A Vector Space Model for Automatic Indexing](https://dl.acm.org/doi/10.1145/361219.361220)\
   向量空间模型的原始论文，"文档与查询同空间、用夹角衡量相关"的思想来源。
2. [Sparck Jones：A Statistical Interpretation of Term Specificity and Its Application in Retrieval（MIT Press 重印版）](https://direct.mit.edu/books/edited-volume/5003/chapter/2657058/A-Statistical-Interpretation-of-Term-Specificity)\
   IDF 概念的起源文献（原载 1972 年 Journal of Documentation），从概率角度论证"术语区分度"。
3. [Manning, Raghavan & Schütze：Introduction to Information Retrieval](https://nlp.stanford.edu/IR-book/)\
   第 6 章系统给出 tf-idf、余弦打分与各种加权变体，是本节公式的教科书依据，全书免费在线。
4. [scikit-learn：TfidfTransformer](https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfTransformer.html)\
   工业实现的精确约定：平滑 IDF 公式、L2 行归一化与 `sublinear_tf` 选项。

## 一页回顾

```
词袋（BoW）：
文档 → 词表维度上的计数向量
保留多重集计数，丢弃顺序
"我 喜欢 自然语言" 与 "自然语言 喜欢 我" 同一向量

TF（局部信号）：
原始计数 tf        线性增长，可被堆词攻击
相对频率 tf/len(d)  长度归一化
对数饱和 1+ln(tf)   次线性增长，BM25 的思想前身

IDF（全局信号）：
idf(t) = ln(N / df(t))
df = 包含该词的文档篇数，不是出现次数
df = N → 权重 0（无区分度）
df = 0 → 未定义（检索侧 OOV，需显式策略）
变体：概率式 ln((N-df+0.5)/(df+0.5))，BM25 用，可为负
     平滑式 ln((1+N)/(1+df))+1，sklearn 默认，恒 ≥ 1

TF-IDF：
w(t,d) = tf(t,d) × idf(t)
局部证据 × 全局区分度
是排序分数，不是概率

余弦排序：
cos(q,d) = (q·d) / (|q||d|)
分子：匹配词加权求和
分母：长度归一；无关的强特征会稀释分数

两个反例（本语料实测）：
去掉 IDF → 排名反转（范数退化为文档长度）
经典 → 平滑 IDF → 排名再次反转
结论：公式、变体、归一化必须随结果一起报告

失效模式：
词汇鸿沟（同义词不同维度）
堆词攻击（线性 TF 被滥用）
词表静态（新词 OOV）
朴素扫描 O(N·|V|) → 用倒排索引（Day 078/014）
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://inwt233.gitbook.io/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-02/day-011.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
