这一节只做一件事:把十二句话拆成词,数它们出现的次数。没有任何「意思」被输入进去。 可数完之后,「猫」最近的邻居是「狗」,「数据」的邻居是「处理」和「程序」。 意思居然从次数里长出来了。
这一节接 11.1 的尾巴。上一节说规则写不全,这里给出另一条路:从语料里统计。 它也是 10.1 的一个回响:那一节说「距离决定一切」,这一节把「距离」从像素搬到词上。 同一套思路,换了个对象。
一、先把词数出来:词袋
十二句手工分词的小语料,一共 30 个词。 排成矩阵,第 i 行第 j 列就是「第 i 句里第 j 个词出现了几次」。 这个矩阵里没有任何「意思」,只有次数。而且顺序丢了—— 「猫 吃 鱼」和「鱼 吃 猫」在这个矩阵里一模一样。 词袋模型的全部内容就是这张表。粗糙得让人怀疑,可它撑起了文本分类十几年。
二、加权:哪个词才管用
光数次数有个明显毛病:「的」「是」这种词每句都有,次数最高,可它什么都不区分。 解决办法是给它降权。 IDF 的思路:出现在越少句子里的词,越有辨识度。公式是 log((1+n)/(1+df)) + 1,n 是句子总数, df 是出现该词的句子数。 「和」「都」「生活」「计算机」这些只出现一次的词,IDF 最高(2.8718); 「狗」出现在四句里,IDF 掉到 1.9555。 查一个词「数据」,三种打分方式的排序:
| 文档 | 原始词频 | 除以词数 | TF-IDF |
|---|---|---|---|
| 第 4 句 | 1.0000 | 0.2500 | 0.5447 |
| 第 5 句 | 1.0000 | 0.2500 | 0.5447 |
| 第 8 句 | 1.0000 | 0.2000 | 0.4357 |
| 老实说这批语料里三条排序一样,因为命中的几句长度差不多。 | |||
| 真实语料里差别会出来:一篇长文把词重复十次,原始词频会把它顶到第一, | |||
| 可它可能只是一篇啰嗦的文章。 |
三、共现:谁跟谁挨着
词袋丢顺序,那就把顺序捡回来一点点。 做法是记「谁跟谁挨着」:取一个窗口,窗口内的词两两记一次共现。 窗口取 2,把共现矩阵的每一行当成一个向量,算余弦相似度。这就是词向量最土的版本。
| 查询词 | 最相近的五个词 |
|---|---|
| 猫 | 骨头(0.61)、老鼠(0.58)、看(0.47)、狗(0.44)、吃(0.43) |
| 狗 | 骨头(0.53)、的(0.47)、猫(0.44)、都(0.40)、家(0.38) |
| 数据 | 计算机(0.64)、的(0.43)、很快(0.43)、处理(0.43)、原料(0.43) |
| 鱼 | 水(0.77)、骨头(0.41)、在(0.41)、猫(0.33)、里(0.31) |
| 鱼和水 0.77,数据和计算机 0.64——这两个数字是靠数邻居数出来的。 | |
| 没人告诉程序「鱼生活在水里」。 | |
| 这就是分布假说:一个词的意思,由它周围常出现的词决定。 | |
| 邻居像,说明用法像;用法像,在这个语料里就等于意思像。 |
四、窗口开多大,学到的东西就不一样
窗口是最重要的一个旋钮。同一批语料,四个窗口:「猫」的最近邻
| 窗口 | 「猫」最相近的五个词 |
|---|---|
| 1 | 狗(0.71)、计算机(0.58)、吃(0.47)、看(0.41)、抓(0.41) |
| 2 | 骨头(0.61)、老鼠(0.58)、看(0.47)、狗(0.44)、吃(0.43) |
| 3 | 骨头(0.47)、吃(0.47)、会(0.41)、喜欢(0.40)、老鼠(0.38) |
| 5 | 狗(0.55)、都(0.54)、和(0.54)、骨头(0.50)、宠物(0.44) |
| 窗口 1 的时候「狗」排第一(0.71),这几乎是这个语料能给的最好答案。 | |
| 窗口放大到 5,「都」「和」这类虚词挤了进来。 | |
| 原因是虚词在哪都出现,窗口一大,它跟谁都共现。 | |
| 所以窗口开大不等于视野更好,反而容易被高频词污染。 |
五、压到两维,再看一眼
把共现矩阵换成正的互信息,做奇异值分解,只留前两个方向。 前三个奇异值是 7.8265、7.3538、6.5704,两个方向只吃掉 26.6% 的能量。 这里有个坑要顺手记下来:降维之后不能用余弦。因为向量全是正的,余弦几乎都是 1.00, 什么都分不出来。改用欧氏距离:
| 查询词 | 2 维空间里的五个近邻 | 和前五名重合 |
|---|---|---|
| 猫 | 狗(0.09)、看(0.28)、抓(0.29)、老鼠(0.30)、家(0.37) | 3/5 |
| 狗 | 猫(0.09)、看(0.21)、老鼠(0.23)、家(0.28)、抓(0.31) | 2/5 |
| 数据 | 处理(0.08)、程序(0.11)、骨头(0.14)、都(0.19)、的(0.23) | 2/5 |
| 鱼 | 在(0.39)、生活(0.39)、里(0.40)、有(0.46)、水(0.54) | 3/5 |
| 猫和狗只差 0.09,这是这张表里最漂亮的一个数。 | ||
| 可「数据」的邻居里混进了「骨头」,说明降维也带来了噪声。 | ||
| 根本原因还是语料太小:30 个词、12 句话,很多词只出现一两次。 | ||
| 真实语料是几十亿词量级,那时候降维才显出真本事——它能把稀疏的共现表压成稠密向量, | ||
| 还能把「猫」和「狗」拉得更近。 | ||
| 最后把三个盲区摆出来,它们都是这套办法的硬伤: |
| 盲区 | 为什么 |
|---|---|
| 否定 | 「喜欢」和「不喜欢」的邻居几乎一样,意思相反 |
| 语序 | 词袋把「狗咬人」和「人咬狗」当成同一句 |
| 一词多义 | 「苹果」在水果和公司两种意思下,只能折成一个向量 |
六、动手 Demo:不懂意思,只数它跟谁一起出现
五段:词袋矩阵、TF-IDF 加权、共现相似度、窗口对比、降维后的近邻。 第 3 段是分水岭:从「数文档里的词」转向「数词与词的邻居关系」。 共现矩阵、IDF、奇异值分解都是手写的。 语料小得可笑,所以每个数都能人工核对——这比跑一个大模型更有说服力。
def cooc(win): # 共现矩阵:谁跟谁挨着,挨了几次
C = np.zeros((len(VOC), len(VOC)))
for d in TOK:
for i, w in enumerate(d):
for j in range(max(0, i - win), min(len(d), i + win + 1)):
if i != j:
C[IDX[w], IDX[d[j]]] += 1.0
return C
# 词权:出现在越少的句子里,越能区分东西
df = (B > 0).sum(0)
idf = np.log((1.0 + n) / (1.0 + df)) + 1.0
tfidf = B * idf
# 降维:把共现表换成正的互信息,再做奇异值分解
pmi = np.maximum(np.log((C + 1e-9) * C.sum() / (C.sum(1, keepdims=True)
* C.sum(0, keepdims=True))), 0.0)
U, S, Vt = np.linalg.svd(pmi, full_matrices=False)
E = U[:, :2] * S[:2] # 每个词两个数,就这点东西
实跑输出:
--------------------------------------------------------------------
不懂意思,只数它跟谁一起出现
--------------------------------------------------------------------
==============================================================
[1] 先做一件很粗暴的事:只数词
十二句话,手工分词(空格隔开),一句一行:
1 猫 喜欢 吃 鱼
2 狗 喜欢 吃 骨头
3 猫 和 狗 都 是 宠物
4 计算机 喜欢 处理 数据
5 程序 处理 数据 很快
6 鱼 生活 在 水 里
7 骨头 是 狗 的 食物
8 数据 是 程序 的 原料
9 宠物 需要 照顾 宠物
10 水 里 有 鱼
11 猫 会 抓 老鼠
12 狗 会 看 家
词表一共 30 个词。把它们排成矩阵,第 i 行第 j 列=第 i 句里第 j 个词出现几次:
文档 猫 喜欢 吃 鱼 狗 骨头 和 都
1 1 1 1 1 0 0 0 0
2 0 1 1 0 1 1 0 0
3 1 0 0 0 1 0 1 1
4 0 1 0 0 0 0 0 0
5 0 0 0 0 0 0 0 0
6 0 0 0 1 0 0 0 0
…(右边还有 22 列)
**这个矩阵里没有任何「意思」,只有次数。**
**顺序也没了:「猫 吃 鱼」和「鱼 吃 猫」在这里一模一样。**
[2] 光数次数不管用,得给词加权
每个词出现在几句话里(文档频率),以及它的 IDF 权重:
词 出现在几句 IDF
和 1 2.8718
都 1 2.8718
生活 1 2.8718
计算机 1 2.8718
很快 1 2.8718
原料 1 2.8718
…
喜欢 3 2.1787
狗 4 1.9555
**「的」「是」这种词几乎每句都有,IDF 最低——它们不区分任何东西。**
**而「老鼠」「原料」只出现一次,IDF 最高。**
查一个词「数据」,两种打分方式给出的排序:
文档 原始词频 除以词数 TF-IDF
4 1.0000 0.2500 0.5447
5 1.0000 0.2500 0.5447
8 1.0000 0.2000 0.4357
**这批语料里三条排序一样——因为命中的两句长度差不多。**
**真实语料里差别会出来:一篇长文把词重复十次,原始词频就会把它顶到第一。**
**可它可能只是一篇啰嗦的文章,跟查询关系不大。**
**除以词数、再乘 IDF,就是为了压住这两种偏差。**
[3] 换个角度:不看文档,看「谁跟谁挨着」
取窗口 2:每个词跟它前后 2 个词各记一次共现。
然后把矩阵每行归一化,算两行的余弦相似度——这就是「词向量」最土的版本。
查询词 最相近的五个词(相似度)
猫 骨头(0.61)、老鼠(0.58)、看(0.47)、狗(0.44)、吃(0.43)
狗 骨头(0.53)、的(0.47)、猫(0.44)、都(0.40)、家(0.38)
数据 计算机(0.64)、的(0.43)、很快(0.43)、处理(0.43)、原料(0.43)
鱼 水(0.77)、骨头(0.41)、在(0.41)、猫(0.33)、里(0.31)
**这就是分布假说:一个词的意思,由它周围常出现的词决定。**
**没人告诉程序「猫」和「狗」都是动物,它只是发现这两个词的邻居长得像。**
**而邻居像,说明用法像;用法像,在这个语料里就等于意思像。**
[4] 窗口开多大,看到的「相似」就不一样
窗口 「猫」最相近的前五个词
1 狗(0.71)、计算机(0.58)、吃(0.47)、看(0.41)、抓(0.41)
2 骨头(0.61)、老鼠(0.58)、看(0.47)、狗(0.44)、吃(0.43)
3 骨头(0.47)、吃(0.47)、会(0.41)、喜欢(0.40)、老鼠(0.38)
5 狗(0.55)、都(0.54)、和(0.54)、骨头(0.50)、宠物(0.44)
**窗口小,抓的是「同一句里挨得近」,更像语法上的搭配;**
**窗口大,抓的是「整段里常一起出现」,更像话题。**
**两种都不是错,取哪个取决于你想让机器学语法还是学主题。**
[5] 压到两个维度,再看一眼
把共现矩阵(先换成正的互信息)做奇异值分解,只留前两个方向。
前三个奇异值:7.8265、7.3538、6.5704
两个方向吃掉了 26.6% 的能量。
在这两个维度里,再看「跟谁近」。**这里不能用余弦。**
**降维后的向量全是正的,余弦几乎都是 1.00,什么都分不出来。改用欧氏距离,越小越近:**
查询词 2 维空间里的五个近邻
猫 狗(0.09)、看(0.28)、抓(0.29)、老鼠(0.30)、家(0.37)
狗 猫(0.09)、看(0.21)、老鼠(0.23)、家(0.28)、抓(0.31)
数据 处理(0.08)、程序(0.11)、骨头(0.14)、都(0.19)、的(0.23)
鱼 在(0.39)、生活(0.39)、里(0.40)、有(0.46)、水(0.54)
跟第 3 段(原始共现空间)比,前五名重合了几个:
查询词 前五名重合个数
猫 3/5
狗 2/5
数据 2/5
鱼 3/5
**注意这不是「谁更准」的问题,是这批语料本身就撑不起语义。**
**一共 30 个词、12 句话,很多词只出现过一两次。**
**真实语料是几十亿词量级,那时候降维才显出好处:**
**它把稀疏的共现表压成稠密的向量,还能顺便把「猫」和「狗」拉到一块儿去。**
把这一节收一下。**这套办法只有一条假设:意思相近的词,出现的上下文也相近。**
**它的三个盲区都是现成的:**
**第一,否定。**「喜欢」和「不喜欢」的邻居几乎一样,可意思相反。
**第二,语序。**词袋把「狗咬人」和「人咬狗」当成同一句。
**第三,一词多义。**「苹果」在水果和公司两种意思下,向量只能是一个折中。
五段连起来:**词袋只数次数,丢了顺序**;**加权之后才知道哪个词管用**;
**共现矩阵把「邻居」变成向量**;**窗口大小决定学语法还是学话题**;
**而语料不够大的时候,降维只是把噪声也一起压扁。**
第 1 段那张矩阵要横着看:一整行里大部分是 0。 这就是「稀疏」两个字的字面样子,也是所有文本算法的起点。 第 3 段那组相似度是本节的心脏。 鱼和水 0.77、数据和计算机 0.64,都不是人写进去的。 第 4 段那张窗口表值得反复看:窗口 1 给出最好的答案(狗 0.71),窗口 5 反而被虚词污染。 所以「窗口越大信息越多」是错的,虚词的问题不解决,观众越多噪声越大。 第 5 段那个「余弦不能用」的坑很有代表性。 降维后的向量全正,余弦天然接近 1——这不是算法坏了,是度量选错了。
30 秒实验
- 把第 1 段语料加两句「猫 讨厌 水」之后重跑第 3 段。 「猫」和「水」的相似度会升——一句话就能改一个词的邻居,这也是这套办法脆弱的证据。
- 把第 2 段的 IDF 公式改成 log(n/df)(不加一的经典写法),重跑。 只出现一次的词权重会变得极大,排序跟着变——平滑那点小改动,影响不小。
- 把第 5 段的维度从 2 改成 10,重跑。 和前五名的重合会上升——维度越低越像「摘要」,越高越接近原表。
常见误区
- 以为「词袋没顺序所以没用」。文本分类里它用了十几年, 因为很多任务(是不是垃圾邮件、什么主题)本来就不靠语序。
- 以为「IDF 越高越重要」。IDF 高只说明它稀有。一个错别字也稀有,IDF 也很高。
- 以为「窗口开大点总没坏处」。Demo 第 4 段:窗口 5 的时候, 「都」「和」挤进了「猫」的最近邻——高频虚词在哪都出现,窗口一大就跟谁都像。
- 以为「降维之后余弦照样能用」。降维结果全为正时, 余弦几乎都是 1.00,排序完全失效。换欧氏距离才测得出来。
自测三问(点开对答案)
1. 「猫」和「狗」在 Demo 里相似,这个结论是怎么来的? —— **纯靠数邻居。**取窗口 2,把每个词前后各两个词记进共现矩阵, **再把矩阵的行当向量算余弦。**「猫」和「狗」都常和「喜欢」「吃」「宠物」挨着, **所以向量像。没有任何人事先告诉程序它们是同类。** 2. 为什么 IDF 要给常见词降权? —— 因为**常见词不区分东西**。 Demo 第 2 段:「和」「都」这类只出现一次的实词 IDF 是 2.8718, **而「狗」出现在四句里,IDF 只有 1.9555。** **如果按原始词频排,「的」「是」永远第一,检索就废了。** 3. 这套办法的三个盲区是什么? —— **否定、语序、一词多义。**「喜欢」和「不喜欢」的邻居几乎一样; **词袋把「狗咬人」和「人咬狗」当成同一句;** **「苹果」在水果和公司两种意思下只能折成一个向量。** **三个盲区的共同点:它们都需要「结构」和「上下文」,而共现矩阵两样都没有。**下一节回到结构上,不过换了算法。 11.1 的回溯能找全所有树,可句子一长就撑不住。 11.3 讲那张「记住中间结果」的表怎么落地成算法——顺带把中文分词这道前置工序也过一遍。
- ☐ (本节完,下一节 11.3 结构分析)