27 维压成 2 维:结构是压缩逼出来的
上一篇的 bigram 把 loss 压到 2.4544 就到头了,原因只有一个:它只看前一个字符。
把上下文扩到 3 个字符,loss 还能往下走。但这一步刚迈出去,统计法自己先塌了 —— 而补救它的办法,顺手回答了一个更大的问题:模型凭什么对没见过的组合也有判断。
上下文从 1 扩到 3,统计法先断了
统计法的全部家当是一张表:上下文有几种组合,表就有几行,每行 27 列。
| 上下文 | 表的格子数 | 数据够不够 |
|---|---|---|
| 1 个字符 | 27² = 729 | 够,627 种真的出现过 |
| 2 个字符 | 27³ = 19,683 | 大部分格子是 0 |
| 3 个字符 | 27⁴ = 531,441 | 22.8 万条数据,怎么都填不满 |
注意分母没变:扩上下文不产生新样本。还是那 32033 个名字、228146 个位置,只是每个位置要看的东西变宽了。53 万个格子摊 22.8 万条数据,平均每个格子连半条都摊不到。
而且问题不是「算不动」,是「填不满」。表里没出现过的组合概率就是 0,模型看到它会说「绝不可能」。上一篇的 model smoothing(给所有计数加 k)能把这些 0 垫起来,但垫出来的是均匀的假概率 —— 里面没有任何关于「这个组合像不像别的组合」的信息。
Bengio 2003 给的答案:让相似的字符共享
A Neural Probabilistic Language Model(Bengio et al., 2003)。论文里的例子:训练数据见过
the cat is walking,没见过a dog was running。但只要模型学到cat ≈ dog、the ≈ a、walking ≈ running,它对没见过的句子也能给出合理概率。
统计法做不到这件事,因为表里每一格都是独立的:an 那一格的计数涨到 5438,一点也影响不到别的格子。
要让它们互相影响,得先让「相似」这个概念在数字上存在。网络结构因此多了一层:
3 个字符(索引) ──embedding──→ 3 × 2 = 6 维(拼接) ──隐藏层 + tanh──→ 100 ──输出层──→ 27对照上一篇那个单层网络:多了 embedding 层和隐藏层,其余(softmax、交叉熵、梯度下降)一个字不用改。这一篇只讲最前面那一段 —— 从索引到 6 个数。
先建数据集:一个滑动窗口
block_size = 3X, Y = [], []for w in words: context = [0] * block_size # [0,0,0] 读作 '...' for ch in w + '.': ix = stoi[ch] X.append(context) Y.append(ix) context = context[1:] + [ix] # 丢掉最左,右边补新的emma 产生 5 个样本:
| 步 | context | 读作 | 要预测 |
|---|---|---|---|
| 0 | [0,0,0] | ... | e |
| 1 | [0,0,5] | ..e | m |
| 2 | [0,5,13] | .em | m |
| 3 | [5,13,13] | emm | a |
| 4 | [13,13,1] | mma | . |
三个细节:
[0] * 3 是列表重复,得到 [0,0,0]。0 是 . 的编号 —— 名字开头没有足够的上文,得用占位符填满。上一篇把起止符合并成一个 . 的好处在这里兑现了第二次:占位符不需要再单开一个编号。
循环靠 w + '.' 自然结束:'emma' 变成 'emma.',遍历完 5 个字符就停,不需要 break。最后那个 . 是第 5 个样本的标签,不是输入 —— 它教模型什么时候该停下来。
形状变化:X 从 (228146,) 变成 (228146, 3),Y 一个字没动。样本数和上一篇完全相同,变的只有每个位置输入的宽度。
block_size = 1时这段代码原样退化成上一篇的 bigram。上下文长度是个超参数 —— 到 Transformer 它会是几百甚至几千,代码结构还是这个形状。
53 万种可能的上下文里,真正出现过的只是很小一部分;而且出现过的那些里,很多只出现一两次 —— 一两次的计数估不出可靠的概率。而 MLP 打算用几千个参数覆盖全部 53 万种。 它凭什么,是本篇后半要回答的。
C[X]:一次索引查完 22 万个样本
C = torch.randn((27, 2)) # 27 个字符,每个 2 维emb = C[X] # (228146, 3) → (228146, 3, 2)两行。第一行是那张「字符 → 向量」的查找表,第二行把 22.8 万 × 3 个字符编号一次全查完。
上一篇末尾那句「one-hot 乘矩阵 ≡ 查表,这就是 embedding 的动机」,在这里兑现:
torch.allclose(F.one_hot(torch.tensor(5), 27).float() @ C, C[5]) # True推导上一篇做过,这里只用结论。
换成上一篇的写法会是什么样
oh = F.one_hot(X, num_classes=27).float() # (228146, 3, 27)emb = oh @ C(228146, 3, 27) 是 1800 万个数,其中 26/27 ≈ 96% 是 0。矩阵乘法要老老实实把这些 0 也乘一遍。
27 个类别时这只是浪费。GPT-2 的词表是 50257 —— one-hot 到那个规模根本存不下。所以查表不是优化技巧,是唯一可行的做法。
索引的形状规律
C[X] 这一步产生了本篇第一个需要停下来想的形状。规律只有一条:
结果形状 = 索引的形状 + 被索引对象剩下的维度| 写法 | 索引的形状 | 剩下的维度 | 结果形状 |
|---|---|---|---|
C[5] | () 标量 | (2,) | (2,) |
C[5:6] | (1,) 切片 | (2,) | (1, 2) |
C[[5,13,13]] | (3,) | (2,) | (3, 2) |
C[X] | (228146, 3) | (2,) | (228146, 3, 2) |
每个整数被换成一个长度 2 的向量,所以在索引形状后面追加一维。
C[5] 和 C[5:6] 数值完全相同、维度数不同,这一对最容易出事:整数索引会降维(「行」这个概念消失了),切片不降维(仍然是「若干行」,只不过只有一行)。上一篇 sum(1) 在一维张量上报 IndexError,就是这个区别的后果。
emb 三个下标各是什么:
| 下标 | 范围 | 含义 |
|---|---|---|
i | 0–228145 | 第几个样本 |
j | 0–2 | 这个样本的第几个字符 |
k | 0–1 | 那个字符向量的第几个分量 |
用一句话读:22.8 万个样本,每个样本是一个 3 × 2 的小矩阵。
ndim 不是线代那个「维」
这里有个容易搞混的地方,麻烦在于两套说法在日常里都在用:
| 对象 | 线代怎么叫 | shape | ndim | 要几个下标 |
|---|---|---|---|---|
3.14 | 标量 | () | 0 | 0 个 |
(a, b) | 2 维向量 | (2,) | 1 | 1 个:v[0] |
m × n 矩阵 | 矩阵 | (m, n) | 2 | 2 个:M[0,1] |
| 一叠矩阵 | 三阶张量 | (k,m,n) | 3 | 3 个 |
线代的「维」数的是分量个数,PyTorch 的 ndim 数的是嵌套层数。 同一个 (a, b):线代说它是 2 维向量,PyTorch 说它是 1 维张量。
m × n 对应的是 shape 不是 ndim —— ndim 恒为 2,变的是 shape 里那两个数。
判断 ndim 最快的办法:打印出来看最外层方括号有几层。
tensor(3.14) 0 层 → ndim 0tensor([1, 2]) 1 层 → ndim 1tensor([[1, 2]]) 2 层 → ndim 2从这一集开始
print(x.shape)值得当成条件反射。到 Transformer 处理四维五维张量时,这是唯一能理清逻辑的办法。
为什么 27 维能压成 2 维
前面都是机械的形状变换。真正的问题在这里:27 个字符,为什么 2 个数就够?
信息确实丢了。但丢掉的是「字符之间毫无关系」这个假设 —— 而这个假设本来就是错的。
one-hot 下,a 和 e 的关系等于 a 和 q 的关系
任意两个 one-hot 向量的点积都是 0,全部两两正交,距离全是 √2。
在这个表示里,a 和 e(都是元音,后面能跟的字符高度相似)的关系,与 a 和 q(q 后面几乎只能跟 u)的关系完全相同。
后果是实打实的:上一篇那张热力图上 an 出现 5438 次,是学得最好的组合之一;而出现次数少得多的那些组合,在 one-hot 下参数完全独立 —— 从 an 的 5438 次里学到的东西,一个字也传不过去。
平面装不下 27 个两两等距的点
这是压缩起作用的地方,而且它是一条几何必然,不是调参调出来的:
d 维空间里两两等距的点最多只有 d + 1 个。 平面上是 3 个(一个正三角形);第 4 个点做不到 —— 放在正三角形中心的话,它到三个顶点的距离确实相等,但那个距离不等于边长。
要让 27 个点两两等距,至少需要 26 维。one-hot 正好就是那 26 维。
反过来说:只给 2 维,就必然有人挨着谁。 模型没得选,只能决定「谁和谁挤在一起」。
压缩是被迫的
关键机制只有一句:相似的输入向量,经过同一个网络会得到相似的输出。
a ≈ e ⟹ a·W₁ ≈ e·W₁ ⟹ 两者的预测也相似所以模型从 an 学到的规律会自动部分迁移到别的组合上。这就是泛化。
而「谁挨着谁」不是设计出来的 —— 训练时梯度会一路传回 C,把行为相似的字符往一起推。
如果 embedding 也是 27 维,模型可以原样退化回 one-hot(每个字符占一个坐标轴),什么结构都不用学。正因为只有 2 维、位置不够分,模型才被迫做取舍 —— 而取舍就是结构。
这个思路叫表示学习:不去设计特征,而是强制降维,让模型自己发现数据的内在结构。
省下来的参数只是副产品:
| 方案 | 输入维度 | 隐藏层 100 时第一层参数 |
|---|---|---|
| one-hot,3 个字符 | 81 | 8100 |
| embedding 2 维 | 6 | 600 |
参数少 = 更不容易过拟合 = 泛化更好。但这是顺带的好处,不是 embedding 存在的理由。
这两个数字在真实模型里是多少
| 模型 | 词表 | embedding 维度 |
|---|---|---|
| 本篇 | 27 | 2 |
| GPT-2 | 50257 | 768 |
| GPT-3 | 50257 | 12288 |
维度一路在涨,但和词表比始终小得多 —— 压缩从来没有取消,只是比例变了。
大规模训练出来的 embedding 有个著名性质:
king − man + woman ≈ queen语义关系变成了向量运算。 它全部源自「位置不够,只能挤」这一个机制。
「像不像」怎么算:点积不是余弦
既然字符之间现在有了远近,就需要一把尺子。
cos θ = (a · b) / (|a| · |b|)def sim(a, b): va, vb = C[stoi[a]], C[stoi[b]] return (va @ vb / (va.norm() * vb.norm())).item()点积和余弦差的正是那个分母:
a · b = |a| · |b| × cos θ └── 两个长度 ──┘点积 = 模长积 × 余弦。 只有两个向量都是单位长度时,两者才相等。
用 (1,1) 和三个同方向的向量验一遍(夹角全是 0°):
| 向量对 | 模长积 | 点积 | 余弦 |
|---|---|---|---|
(1,1) 与 (1,1) | 2 | 2 | 1.0 |
(1,1) 与 (2,2) | 4 | 4 | 1.0 |
(1,1) 与 (10,10) | 20 | 20 | 1.0 |
方向一步没动,点积一路飙到 20。 类比:点积 ↔ 走了多远,余弦 ↔ 朝哪个方向。「往东北走 1 公里」和「往东北走 10 公里」方向完全相同,距离差十倍。
为什么必须除掉长度
训练时高频字符被更新的次数多,向量往往会变长。
- 用点积:高频字符向量长,和谁点积都大 —— 看起来「和所有字符都相似」
- 用余弦:长度被除掉,所有字符站在同一起跑线上比方向
归一化之后,点积就是余弦
(a · b) / (|a| · |b|) = (a/|a|) · (b/|b|) = â · b̂所以大规模检索时先把向量全部归一化,之后一次矩阵乘法就算出全部余弦:
Cn = C / C.norm(dim=1, keepdim=True)sim_matrix = Cn @ Cn.T这个公式后面会天天见。 RAG 检索就是:把文档和问题都编码成向量,用余弦找最接近的几篇。同一个式子,从 27 个字符扩展到几万篇文献 —— 变的只有
C有多少行、每行多少维。
一句话概括这一篇:把 27 个两两垂直的字符压进一张平面,相似的才有机会靠在一起 —— 结构不是设计出来的,是维度不够逼出来的。
现在每个样本手上是 3 个 2 维向量,一共 6 个数。把它们拼起来送进隐藏层、再送到 27 个 logits,是下一篇的事。
下一篇会撞上两处「换个写法就出事」:把 3 个向量拼成 6 个数有两种写法,算 loss 也有两种写法 —— 每一组都算出同样的数,代价却完全不同。