bobofamilyNIU CHUN
09/36

27 维压成 2 维:结构是压缩逼出来的

2026-09-0510 分钟—阅读

上一篇的 bigram 把 loss 压到 2.4544 就到头了,原因只有一个:它只看前一个字符。

把上下文扩到 3 个字符,loss 还能往下走。但这一步刚迈出去,统计法自己先塌了 —— 而补救它的办法,顺手回答了一个更大的问题:模型凭什么对没见过的组合也有判断。

上下文从 1 扩到 3,统计法先断了

统计法的全部家当是一张表:上下文有几种组合,表就有几行,每行 27 列。

上下文表的格子数数据够不够
1 个字符27² = 729够,627 种真的出现过
2 个字符27³ = 19,683大部分格子是 0
3 个字符27⁴ = 531,44122.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 = 3
X, 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]..em
2[0,5,13].emm
3[5,13,13]emma
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 三个下标各是什么:

下标范围含义
i0–228145第几个样本
j0–2这个样本的第几个字符
k0–1那个字符向量的第几个分量

用一句话读:22.8 万个样本,每个样本是一个 3 × 2 的小矩阵。

ndim 不是线代那个「维」

这里有个容易搞混的地方,麻烦在于两套说法在日常里都在用:

对象线代怎么叫shapendim要几个下标
3.14标量()00 个
(a, b)2 维向量(2,)11 个:v[0]
m × n 矩阵矩阵(m, n)22 个:M[0,1]
一叠矩阵三阶张量(k,m,n)33 个

线代的「维」数的是分量个数,PyTorch 的 ndim 数的是嵌套层数。 同一个 (a, b):线代说它是 2 维向量,PyTorch 说它是 1 维张量。

m × n 对应的是 shape 不是 ndim —— ndim 恒为 2,变的是 shape 里那两个数。

判断 ndim 最快的办法:打印出来看最外层方括号有几层。

tensor(3.14) 0 层 → ndim 0
tensor([1, 2]) 1 层 → ndim 1
tensor([[1, 2]]) 2 层 → ndim 2

从这一集开始 print(x.shape) 值得当成条件反射。到 Transformer 处理四维五维张量时,这是唯一能理清逻辑的办法。

为什么 27 维能压成 2 维

前面都是机械的形状变换。真正的问题在这里:27 个字符,为什么 2 个数就够?

信息确实丢了。但丢掉的是「字符之间毫无关系」这个假设 —— 而这个假设本来就是错的。

从左到右是一条推理链:27 维里两两等距 → 平面最多容得下 3 个两两等距的点 → 27 个点必然有人挨着。

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 个字符818100
embedding 2 维6600

参数少 = 更不容易过拟合 = 泛化更好。但这是顺带的好处,不是 embedding 存在的理由。

这两个数字在真实模型里是多少

模型词表embedding 维度
本篇272
GPT-250257768
GPT-35025712288

维度一路在涨,但和词表比始终小得多 —— 压缩从来没有取消,只是比例变了。

大规模训练出来的 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)221.0
(1,1) 与 (2,2)441.0
(1,1) 与 (10,10)20201.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 也有两种写法 —— 每一组都算出同样的数,代价却完全不同。