概率就是面积,矩阵就是映射:大模型最底层的两块拼图
概率就是面积,矩阵就是映射:大模型最底层的两块拼图
一个常见的疑惑是:为什么大模型论文动不动就是 $P(y\mid x)$、$\arg\max W x$、$\mathrm{softmax}(QK^\top/\sqrt d)$?这些符号背后到底在做什么直觉上的事情?
这篇文档只讲两件事:
- 概率就是面积——所有
softmax、cross_entropy、top-p、temperature都是在切、推、压一块面积为 1 的"蛋糕"。- 矩阵就是映射——所有
nn.Linear、nn.Embedding、Q/K/V投影、lm_head都是把一个向量搬运、旋转、拉伸到另一个向量。把这两个直觉钉牢,再看
model.py:177-198的forward()就只剩一句话:先一连串映射把 token id 变成一个向量,再把那个向量映射成 logits,最后 softmax 把 logits 摆成一块面积,从面积里抽样下一个 token。
目录
- 一句话拼图:forward 就是 "映射 → 面积"
- 概率就是面积:从直方图说起
- MNIST:把"概率分布"和"下一个 token"画给你看
- softmax:把任意 logits 折成"总面积 = 1"
- cross-entropy:你在正确答案上摆了多少面积
- temperature / top-k / top-p:三种切蛋糕的方法
- 矩阵就是映射:从 y = Wx 开始
- Embedding 是查表,底层是矩阵乘
- Q、K、V:同一个向量被三种映射"分成三份"
- lm_head:从语义空间映回词表空间
- 多层堆叠 = 映射的复合
- 训练时发生了什么:面积形状被往正确答案上拉
- 大模型用了几种概率分布?为什么处处是高斯
- 通俗收束:水池模型 + 折纸模型
1. 一句话拼图:forward 就是 "映射 → 面积"
先把全文要论证的结论摆出来。model.py:177-198:
# model.py:177
def forward(self, idx, targets=None):
...
tok_emb = self.transformer.wte(idx) # 映射 1:id → 向量
pos_emb = self.transformer.wpe(pos) # 映射 2:位置 → 向量
x = self.transformer.drop(tok_emb + pos_emb)
for block in self.transformer.h:
x = block(x) # 映射 3..14:12 层 Block
x = self.transformer.ln_f(x)
logits = self.lm_head(x) # 映射 15:向量 → 词表
loss = F.cross_entropy(...) # 把 logits 当作面积,看正确答案占多少
读完整个流程:
- 前 14 步全是矩阵乘——把 token id 一步步搬运到一个 768 维的语义向量。
- 第 15 步 lm_head 是最后一次映射——把 768 维向量映回 50304 维的词表 logits。
- 最后一步 cross_entropy / softmax 是面积——把 50304 个数捏成一块概率面积,看你在正确答案那一格上铺了多少。
后面所有节都是把这十几行展开讲。
2. 概率就是面积:从直方图说起
2.1 离散概率:把面积切成 N 个柱子
掷一枚骰子,6 个面每个 1/6。把它画成直方图:
P
│
1/6 ┤ ▮ ▮ ▮ ▮ ▮ ▮
└─1──2──3──4──5──6 (点数)
每根柱子的高度就是概率。所有柱子加起来的总面积 = 1——这是概率论里那条最朴素的公理:$\sum_i P(x_i) = 1$。
如果柱子宽度都设为 1(默认),那么"高度"和"面积"是一回事。但一旦你切到连续分布,就必须用面积这个词:
2.2 连续概率:高度可以 > 1,但面积必须 = 1
正态分布 $\mathcal{N}(0, 0.01^2)$ 在 0 处的概率密度可能高达 39.9,但它只是"密度"——只有把一段区间下的面积积分出来,才是概率。
import numpy as np
# 一个尖窄的正态分布,中心高度可达 ~40
x = np.linspace(-0.05, 0.05, 1000)
pdf = np.exp(-x**2 / (2*0.01**2)) / (0.01 * np.sqrt(2*np.pi))
print(pdf.max()) # ~39.89, 高度可以 > 1
print(np.trapz(pdf, x)) # ~1.0, 但面积一定 = 1
记住这条铁律:概率 = 面积,永远 = 1。所有的"分布在变化",本质上都是这块面积在 x 轴上被推来推去、压扁或堆尖——但总量守恒。
2.3 在大模型里,"分布"是 50304 根柱子
CodeGPTConfig.vocab_size = 50304(model.py:112)。每次预测下一个 token,模型输出的是一个长度为 50304 的概率向量,对应 50304 根柱子,它们的高度加起来 = 1。
# model.py:301
probs = F.softmax(logits, dim=-1) # shape: (B, 50304),每一行是一个直方图
probs.sum(dim=-1) # 所有元素 = 1.0
下一节用 MNIST 这个最朴素的"10 类分类"例子,把这块直方图画给你看。看完之后再回来理解 50304 根柱子,就只是数字变大了一些而已。
3. MNIST:把"概率分布"和"下一个 token"画给你看
回到一个最朴素的问题:
"给定 token 词典,输出下一个 token 的概率分布"——是不是指模型输出 "数字 3 占 80%、数字 2 占 10% ..." 这样一组百分比?下一个 token 就是其中概率最大的那个 3?
正是这个意思。这一节用 MNIST 手写数字识别——deep learning 入门第一课——把这块"分布"长什么样、"下一个 token"是怎么从分布里挑出来的,画清楚。MNIST 是 next-token 预测的最小可视化版本:词典只有 10 个"token"(数字 0~9),每张图片只预测 1 次。
3.1 MNIST:10 类分类的最小模型
MNIST 任务:输入一张 28×28 的手写数字图片,输出"这是 0 到 9 中的哪一个"。
最小的能跑的模型只有两层 Linear:
import torch.nn as nn
import torch.nn.functional as F
class MnistMLP(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(28*28, 128)
self.fc2 = nn.Linear(128, 10) # 10 类 = 10 个 logit
def forward(self, x):
h = F.relu(self.fc1(x.view(-1, 28*28)))
logits = self.fc2(h) # shape: (batch, 10)
return logits
输入是图片(被拉平成 784 维向量),中间过一个 128 维的隐藏层,最后用一个 Linear 把 128 维向量映射到 10 个 logit。这 10 个数字就是原始打分——可以是任意实数(有正有负),还没有归一化。
3.2 logits → 概率分布:还是 softmax
把 logits 喂给 softmax,就得到一个长度为 10 的概率分布:
logits = model(image)
# 比如对一张写得很标准的 "3":
# logits ≈ [0.1, 0.2, 1.5, 8.2, 0.3, 1.0, 0.8, 1.1, 0.5, 0.4]
probs = F.softmax(logits, dim=-1)
# 经过 softmax 之后大约:
# probs ≈ [0.000, 0.000, 0.001, 0.997, 0.000, 0.001, 0.001, 0.001, 0.000, 0.000]
如果模型学得不那么自信(比如这个 "3" 写得像 "8"),分布会更平:
P
│
0.8 ┤ ▮
0.6 ┤ ▮
0.4 ┤ ▮
0.2 ┤ ▁ ▮ ▁
0.05┤ ▁ ▮ ▁ ▮ ▁ ▁ ▁ ▁ ▁ ▮ ▁
└──0──1──2──3──4──5──6──7──8──9 (类别)
这就是用户原问题里说的"数字 3 占 80%、数字 2 占 10%、数字 8 占 5% ..."那个分布。10 根柱子的高度加起来 = 1,这正是 §2 里反复说的"概率就是面积"——MNIST 的输出是 10 根柱子的面积分布,是 §2 那张直方图最朴素的实例。
3.3 "下一个 token 就是这个 3 吗?"——argmax vs 抽样
用户的第二个问题:"下一个 token 就是这个概率最大的 3 吗?" 答案分两半:
MNIST 这种分类任务——是。 永远取概率最大的那一类(argmax):
prediction = probs.argmax() # tensor(3)
为什么?因为分类任务里"答案是确定的"——这张图要么是 3,要么是 8,没有"创造性"可言。
GPT 生成任务——通常不是。 如果永远 argmax,输出会僵硬重复——同一个 prompt 每次都吐同一个 token,写诗写代码毫无变化。所以 GPT 大多数情况下从概率分布里抽样:
# model.py:301-302
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1) # 按面积比例抽
multinomial 字面意思是"按每根柱子的面积比例扔骰子"。如果某 token 的柱子面积是 0.80,它就有 80% 的概率被选中——但还有 20% 的概率被别的 token 替代。这就是为什么同一个 prompt 给 GPT,每次回答略有不同。
温度 = 0 时 multinomial 退化成 argmax(贪心解码),这时 GPT 的行为就和 MNIST 完全一样——每次都选概率最大的 token。日常调用 ChatGPT、Claude 时如果想让回答更稳定可重复,就把 temperature 调到接近 0。
总结:分类任务取 argmax,生成任务做抽样。但底下那块"N 根柱子的概率分布"是同一个东西——50304 根 vs 10 根的差别而已。
3.4 一张表把 MNIST 和 CodeGPT 摆在一起
把 MNIST 模型和 CodeGPT 摆在一起对照,结构完全一致:
| MNIST | CodeGPT | |
|---|---|---|
| 输入 | 28×28 = 784 维像素 | N 个 token id |
| 编码器 | 2 层 MLP(fc1 + fc2) |
embedding + 12 层 Transformer Block |
| 隐藏向量 | 128 维 | 768 维 |
| 最后一层 | nn.Linear(128, 10) |
lm_head: nn.Linear(768, 50304) |
| logits 维度 | 10 | 50304 |
| softmax 后 | 10 根柱子,面积 = 1 | 50304 根柱子,面积 = 1 |
| loss | F.cross_entropy(logits, label) |
F.cross_entropy(logits, target_token) |
| 推理 | argmax 选数字 |
multinomial 抽 token(temperature=0 时退化为 argmax) |
唯一的实质区别:MNIST 类别数是 10,词表大小是 50304;MNIST 中间用 MLP,GPT 中间用 Transformer。输入接入头和输出接出头完全一样:一组 logits → softmax → 概率面积 → 选答案。
所以下次再听到 "next-token prediction" 这个词,可以直接翻译成:
就是 MNIST 那种 N 类分类,N 从 10 变成了 50304;并且每生成一个 token 就重做一次(自回归)。
§4 的 softmax、§5 的 cross-entropy、§6 的 temperature/top-p——所有这些机制 MNIST 同样适用(虽然分类任务不需要花式采样),它们不是"大模型才有的高级技巧",而是任何用 softmax + cross-entropy 训练的分类模型都共享的同一套机器。GPT 把这套机器复用了几万亿次而已。
3.5 一个细节:GPT 每个位置都在做"分类"
MNIST 一张图片 → 一次 10 类分类。 GPT 一段 N 个 token → N 次 50304 类分类(并行)。
看 model.py:191:
logits = self.lm_head(x)
# x.shape = (batch, T, 768)
# logits.shape = (batch, T, 50304)
序列里每一个位置都各自输出一组 50304 个 logits。训练时 cross-entropy 在每个位置上都监督一次:"位置 t 的下一个 token 应该是什么?"——所以一段长度 1024 的序列,等于在一次前向里同时做了 1024 个 MNIST 风格的 50304 类分类。这就是为什么 GPT 训练这么吃 GPU——并行做的分类问题数量大得多。
4. softmax:把任意 logits 折成"总面积 = 1"
4.1 核心一行
F.softmax 干的事情用 Python 写出来就是:
def softmax(z):
e = (z - z.max()).exp() # 1. 取指数,把负数变正数
return e / e.sum() # 2. 归一化,让总和 = 1
直觉是这样的:
- 任意一组实数 $z_1, z_2, \ldots, z_n$(可能有正有负有几十有上百),不可能直接当概率——概率必须非负且和为 1。
- 第一步用
exp把它们都拉到正数(指数永远 > 0)。指数还有个绝佳性质:差距被放大——$z_i$ 比 $z_j$ 大 1,$e^{z_i}$ 就比 $e^{z_j}$ 大 e 倍。 - 第二步除以总和,确保面积 = 1。
4.2 几何直觉:把一根折线压成一块面积
想象 logits 是一根任意起伏的折线:
logits: -2 ▁
5 ████████
1 ▂▂
8 ████████████
-3 ▁
softmax 干的事是:先把每个柱子换成 $e^{z_i}$(高的更高、低的更低),然后把这一坨缩放到总面积 1:
softmax: . (几乎 0)
▂▂
▁
███ (大头都吸过来了)
.
logits 的"形状"决定了面积怎么分配——大的 logit 把面积吸过来,小的 logit 几乎被压成 0。这就是为什么我们叫这个分布是"由 logits 参数化"的。
4.3 模型在做什么
每一次前向,CodeGPT 输出 50304 个 logits。softmax 把它们捏成 50304 根柱子,总面积 = 1。生成时就从这块面积里抽样:
# model.py:301-302
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1) # 按面积比例抽样
torch.multinomial 字面意思就是"按照每根柱子的面积比例扔骰子"——面积越大的 token 越容易被抽到。模型根本不知道"语法"或"语义",它只知道当前上下文下 50304 根柱子的面积分布是什么。
5. cross-entropy:你在正确答案上摆了多少面积
5.1 一句话总结
训练时的 loss 函数是 F.cross_entropy(model.py:192)。它干的事就一个:
看模型在"正确答案那一格"上铺了多少面积,铺得越少,loss 越大。
数学写法:$\mathrm{loss} = -\log P(\text{正确 token})$。代码写法更直白:
probs = F.softmax(logits, dim=-1) # 50304 根柱子
correct_token = targets # 正确 token 的 id
prob_of_correct = probs[correct_token] # 拿出对应那一格的面积
loss = -torch.log(prob_of_correct) # 越接近 1, log 越接近 0; 越接近 0, log 越大
回到 §3 的 MNIST 例子就更直观了:图片是数字 "3",模型在第 3 格摆了 0.997 的面积——loss = -log(0.997) ≈ 0.003,几乎无可挑剔;但如果模型在第 3 格只摆了 0.10——loss = 2.30,挨揍。GPT 训练就是把这种监督在 50304 个类、每个序列位置同时做。
5.2 几何直觉
正确答案是 50304 根柱子里的某一根。理想情况是模型把所有面积都堆到那一根上(高度 = 1,其余 = 0)。但模型不知道答案,它只能根据上下文做出最合理的分布。
- 如果模型在那一格摆了 0.9 的面积:loss ≈ $-\log 0.9$ ≈ 0.10,几乎没事。
- 如果摆了 0.1:loss ≈ $-\log 0.1$ ≈ 2.30,挨揍。
- 如果摆了 0.0001:loss ≈ 9.21,挨大揍。
训练就是不断让模型把面积往正确答案那根柱子上推。所有"模型在学习"的过程,从面积视角看,就是这块直方图的形状一步步被磨成"该尖的地方尖、该平的地方平"的过程。
5.3 为什么 ignore_index = -1 出现在 FIM 里
F.cross_entropy(..., ignore_index=-1)(model.py:192)的意思是:target 等于 -1 的位置,不计算 loss、不要求模型在那块面积上做任何事。
tokenizer.py 里 apply_fim_transform 把 <|fim_pad|> 位置的 target 设成 -1,就是告诉模型:"这些位置是为了凑齐 block_size 的 padding,那块面积长什么样我都不管。" 否则模型会被迫去把面积堆到 padding token 上,把整块概率分布带偏。
6. temperature / top-k / top-p:三种切蛋糕的方法
推理时所有"调风格"的旋钮,本质上都是在对那块面积做手脚。看 model.py:279-302 这一段:
6.1 temperature:把面积压扁还是堆尖
# model.py:279
logits = logits[:, -1, :] / temperature
把 logits 整体除以一个温度 $T$:
- $T > 1$:所有 logit 被压缩 → 高低差变小 → softmax 后面积更平(更随机、更有创意、也更可能胡说)。
- $T < 1$:所有 logit 被拉大 → 高低差更悬殊 → 面积更集中在最大的几根柱子上(更确定、更保守)。
- $T \to 0$:面积全部堆到最高那根柱子上(贪心解码)——此时
multinomial退化为argmax,GPT 的行为和 MNIST 一样。
物理上这个 $T$ 字面就是温度——softmax 等价于 Boltzmann 分布,温度高分子乱跑,温度低粒子都坐到能量最低处(参见 PHYSICS_AND_DEEP_LEARNING.md)。
6.2 top-k:只留最高的 k 根柱子
# model.py:287-289
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = -float('Inf')
把除了 top-k 之外的所有柱子的 logit 设成 $-\infty$,softmax 后它们的面积变成 0。剩下 k 根柱子重新归一化,总面积 = 1。直接砍掉长尾。
6.3 top-p(nucleus sampling):按面积切蛋糕
# model.py:292-299
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
sorted_indices_to_remove = cumulative_probs > top_p
更聪明的做法:把柱子按高度从大到小排好,累计面积从 0 开始加,加到刚好 $\geq p$(比如 0.9)就停。前面这一截"核心面积"(nucleus)保留,后面的全砍。
为什么这比 top-k 更合理?因为不同上下文下"该保留多少根柱子"是动态的:
- "1 + 1 = ?" 这种问题,可能只有一根柱子("2")面积就有 0.99,top-p=0.9 时只保留这 1 根。
- "请写一首关于秋天的诗" 这种开放问题,可能 200 根柱子才凑齐 0.9 的面积,top-p 自动保留 200 根。
top-k 是固定切刀数,top-p 是固定切面积。前者僵硬,后者自适应。
7. 矩阵就是映射:从 y = Wx 开始
讲完面积,换另一半拼图——矩阵。
7.1 一行代码就是全部
import torch.nn as nn
fc = nn.Linear(in_features=4, out_features=3, bias=False)
y = fc(x) # x: (4,) -> y: (3,), 等价于 y = W @ x, 其中 W 是 (3, 4) 的矩阵
nn.Linear 不是"线性层"这种神秘的东西,它就是 矩阵乘。一个 $(3, 4)$ 的矩阵 $W$ 接受一个 4 维向量,吐出一个 3 维向量。
7.2 几何直觉:矩阵把空间搬来搬去
二维平面上看最直观:
- 旋转矩阵:$\begin{bmatrix}\cos\theta & -\sin\theta \ \sin\theta & \cos\theta\end{bmatrix}$ 把平面所有点绕原点旋转 $\theta$ 度。
- 拉伸矩阵:$\begin{bmatrix}2 & 0 \ 0 & 1\end{bmatrix}$ 把平面横向拉成 2 倍。
- 投影矩阵:$\begin{bmatrix}1 & 0 \ 0 & 0\end{bmatrix}$ 把平面所有点压到 x 轴上(降维)。
代码上看:
import torch
W_rot = torch.tensor([[0.0, -1.0], [1.0, 0.0]]) # 旋转 90 度
v = torch.tensor([1.0, 0.0]) # 沿 x 轴方向
W_rot @ v # tensor([0., 1.]) — 变成沿 y 轴
任何矩阵都可以理解成"把输入空间的每一个向量映射到输出空间的某个向量"——它是一个函数,输入是向量,输出是向量。"映射"就是函数的几何说法。
7.3 高维空间的映射也是同一件事
到了 768 维的隐藏空间、50304 维的词表空间,几何画不出来,但直觉一样:
- $W \in \mathbb{R}^{m \times n}$ 是一个从 n 维空间到 m 维空间的映射。
- 输入向量 $x \in \mathbb{R}^n$,输出向量 $y = Wx \in \mathbb{R}^m$。
- 矩阵的每一行可以理解成 $m$ 个"探测器",每根探测器在 n 维空间里有一个偏好方向。
- $y_i$ 就是 $x$ 沿着第 $i$ 个探测器方向的"投影长度"。
这个直觉到了 attention 那里会反复用到。
7.4 回到 MNIST:lm_head 等价物就是 50304 个探测器
MNIST 最后一层 nn.Linear(128, 10) 的 10 行就是 10 个"数字探测器"——分别在 128 维隐藏空间里对应"是 0 吗"、"是 1 吗"……"是 9 吗"。y_i 越大,模型越觉得这张图是数字 i。
CodeGPT 的 lm_head: nn.Linear(768, 50304) 是同一件事:50304 个 token 探测器,每个探测器在 768 维语义空间里有一个偏好方向,专门负责回答"下一个 token 是不是我?"——logits[i] 越大,token i 就越有可能被选中。
8. Embedding 是查表,底层是矩阵乘
8.1 wte 是一张大表
# model.py:145
self.transformer.wte = nn.Embedding(config.vocab_size, config.n_embd)
# shape: (50304, 768)
nn.Embedding 看起来像"查字典":给一个 token id(整数),返回对应的 768 维向量。但底层就是矩阵乘:
wte.weight是一个 $(50304, 768)$ 的矩阵 $W$。wte(idx)等价于"取出 $W$ 的第idx行"。- 也等价于"把 idx 表示成一个 50304 维的 one-hot 向量 $e_{idx}$,然后做 $W^\top e_{idx}$"。
# 这两行结果完全一样
v1 = wte.weight[token_id]
v2 = wte.weight.T @ F.one_hot(token_id, num_classes=50304).float()
第二种写法浪费——但它揭示了embedding 就是矩阵乘的特例:one-hot 当输入时,矩阵乘退化成查表。
8.2 这张表是被训练出来的
wte 一开始是随机的(model.py:171-175 的 _init_weights)。每次反向传播都在调整这张表的某些行——具体哪些行被调整?只有这次 batch 里真正出现过的那些 token id 对应的行。
训练完之后: - 含义相近的 token 被推得几何距离接近(cos 相似度高)。 - "function"、"def"、"return" 这些代码关键词被聚成一团;"+"、"-"、"*" 是另一团。 - 所谓的"语义空间"就是这张表在训练后的形状。
这是大模型最神奇的事情之一:离散的 token id 通过一次矩阵乘被映射成连续的语义向量,从此可以做加减法、做 attention、做线性插值。
9. Q、K、V:同一个向量被三种映射"分成三份"
9.1 一行代码做三件事
# model.py:36
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
# 768 -> 2304, 然后 split 成三份各 768
# model.py:53
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
数学上等价于三个独立矩阵:
$$ Q = W_Q x, \quad K = W_K x, \quad V = W_V x $$
同一个输入向量 $x$,被三种不同的映射变成三个角色:
- $Q$(Query,问题):当前 token 在"问"什么?
- $K$(Key,钥匙):每个 token 自己"是关于什么的"?
- $V$(Value,内容):每个 token 想要被"传递"的实际内容是什么?
9.2 注意力 = Q 在 K 群里搜匹配,按面积加权抽 V
# model.py:66-70
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1))) # Q·Kᵀ:相似度矩阵
att = att.masked_fill(self.bias[:, :, :T, :T] == 0, float('-inf'))
att = F.softmax(att, dim=-1) # 把每一行变成面积 = 1 的分布
y = att @ v # 按面积加权求和 V
注意这里两块拼图同时出现:
- Q、K、V 是三次矩阵映射——把 $x$ 搬到三个不同的子空间。
- $Q \cdot K^\top$ 是一组打分,softmax 后变成面积 = 1 的分布——给每一个 V 应该被取多少作出概率回答。
att @ v是按面积加权求和——它字面上就是数学期望 $\mathbb{E}_{\text{att}}[V]$。
所以 attention 的口号"Query 找 Key,按相似度加权 Value"翻译成本文的语言是:
三次矩阵映射把 $x$ 拆成三个角色,相似度被 softmax 折成一块面积,从这块面积里求 V 的加权平均。
10. lm_head:从语义空间映回词表空间
# model.py:151
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
# 768 -> 50304
最后一层 lm_head 是一个 $(50304, 768)$ 的矩阵——它和 wte 形状互为转置。事实上 CodeGPT 还做了"权重绑定"(weight tying):
# model.py:153
self.transformer.wte.weight = self.lm_head.weight
这两个矩阵共享同一份参数。这意味着:
- 进入网络时:用这张表把 token id 映射到 768 维语义向量(取一行)。
- 离开网络时:用同一张表把 768 维语义向量映射回 50304 维 logit 向量(做矩阵乘)。
把它当一对反向操作来理解:embedding 是"打开"——把符号变成几何向量;lm_head 是"关闭"——把几何向量变回符号空间的得分(logit)。最后 softmax 把这些得分捏成面积,抽样得到下一个 token——这一步就是 §3 MNIST 那张直方图的高维放大版。
11. 多层堆叠 = 映射的复合
# model.py:186-187
for block in self.transformer.h:
x = block(x)
12 层 Transformer Block 就是把 12 个映射函数复合起来:
$$ x_\text{out} = f_{12} \circ f_{11} \circ \cdots \circ f_1(x_\text{in}) $$
每个 $f_i$ 内部又是一连串矩阵乘:ln_1 → c_attn → c_proj → ln_2 → c_fc → c_proj。整个 GPT 看作一个超长的函数,参数加起来 124M,本质上就是一个复合映射。
而残差连接(model.py:103-104)的几何意义是:
x = x + self.attn(self.ln_1(x)) # 不是替换 x,是在 x 上加一个修正
x = x + self.mlp(self.ln_2(x))
每一层不是"重写"输入向量,而是给它加上一个微小的位移。整条 12 层网络可以理解成"把一个原始向量沿着学到的方向,分 24 步(12 attn + 12 mlp)一点点搬到目的地"。从这个视角看,深网络是离散化的常微分方程——参见 PHYSICS_AND_DEEP_LEARNING.md 里把残差解释成 Euler 法的那一段。
12. 训练时发生了什么:面积形状被往正确答案上拉
把"映射"和"面积"两块拼图结合起来,才能看清训练在做什么。看 train.py 主循环里那一行:
logits, loss = model(X, Y) # 前向:映射链 + 算面积上的 loss
loss.backward() # 反向:把 loss 的梯度沿映射链一路推回去
optimizer.step() # 更新所有矩阵的参数
发生的事情是:
- 前向:一连串矩阵乘把 $X$ 搬到 logits,softmax 后得到一块面积;cross-entropy 度量"正确 token 那一格的面积有多小"。
- 反向:对每个矩阵元素求导——"如果我把这个矩阵元素调大 0.0001,正确答案那一格的面积会涨多少?"
- 梯度下降:每个矩阵元素都按"涨多少"的方向被调整一点点。
学习的实质:所有矩阵(wte、W_Q、W_K、W_V、c_proj、c_fc、lm_head)的元素都在被反复微调,目的是让最终那块概率面积,在正确答案的柱子上越铺越高。
12 万个参数也好、124 万也好、1240 亿也好,干的都是同一件事:调整一连串矩阵映射,让最终那块面积在数据告诉它应该高的地方变高。MNIST 训练和 GPT 训练用的是同一个 loss、同一种梯度下降——只是 GPT 这边并行在做几千亿次"分类"而已。
13. 大模型用了几种概率分布?为什么处处是高斯
本节回答两个常被一起问出来的疑惑:
大模型里只用到正态分布吗?为什么那么多概率分布不用,就盯着一条钟形曲线?是中心极限定理吗?
短答案:不是只用高斯,但高斯包揽了"内部装修"的绝大部分。输出端反而压根不是高斯——而是 §3-§6 反复讲的那个 50304 根柱子的离散类别分布。两端各司其职,原因有三:中心极限定理(CLT)、最大熵原理、矩阵乘的闭式可计算性。
13.1 大模型里其实有四种分布
只翻一遍 model.py 这 400 行代码,至少能数出四种概率分布在工作:
| 分布 | 在哪里出现 | 角色 |
|---|---|---|
| 类别分布 / Multinomial(离散) | softmax(logits) 输出(model.py:301)、torch.multinomial 抽样(model.py:302) |
输出层——50304 根柱子的离散概率 |
| 正态分布 / Gaussian(连续) | torch.nn.init.normal_(model.py:171, 175, 159)、LayerNorm(model.py:28)、GELU(model.py:81) |
内部装修——参数初始化、激活分布形状 |
| 伯努利分布 / Bernoulli(0/1) | nn.Dropout、attention causal mask(model.py:67)、FIM 50% 触发 |
开关——随机丢、随机切 |
| 均匀分布 / Uniform | np.random.randint 选 FIM 切点、训练样本 shuffle |
不偏不倚的随机抽签 |
§3-§6 反复讲的"50304 根柱子的概率面积"是类别分布——这才是 GPT 输出端真正用的分布,不是高斯。这一点必须钉死:
输出端不可能是高斯——高斯是连续分布,token 是离散符号,钟形曲线没法铺到 50304 个整数格点上。
那么"高斯无处不在"指的是哪里?是模型内部——参数、激活、初始化、归一化——的隐性规范。下一节把 model.py 里所有用到正态分布的地方钉出来。
13.2 高斯藏在哪:参数与激活的"内部装修"
(a) 权重初始化——所有矩阵开局都是高斯样本
# model.py:169-175
def _init_weights(self, module):
if isinstance(module, nn.Linear):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
elif isinstance(module, nn.Embedding):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
# model.py:157-159 残差投影特殊缩放
for pn, p in self.named_parameters():
if pn.endswith('c_proj.weight'):
torch.nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * config.n_layer))
所有 nn.Linear 和 nn.Embedding 的权重一开始都是从 $\mathcal{N}(0, 0.02^2)$ 里抽出来的样本——不是均匀分布、不是 0、不是 Xavier 那种 $\mathcal{U}$,是高斯。1.24 亿参数全是钟形曲线下抽出来的随机数。为什么是 0.02 这个具体数字?下一节用 CLT 算给你看。
(b) LayerNorm 把激活反复"重新捏回标准正态"
# model.py:28
return F.layer_norm(input, self.weight.shape, self.weight, self.bias, 1e-5)
LayerNorm 字面就在做:
def layer_norm(x):
mean = x.mean(dim=-1, keepdim=True)
std = x.std(dim=-1, keepdim=True)
return (x - mean) / (std + 1e-5) # 减均值、除标准差
(x - mean) / std 就是把任意分布"标准化"成均值 0、方差 1——也就是规整成"长得像 $\mathcal{N}(0,1)$"。即便输入不是严格高斯,经过这一步后也被强行整形成"形状像高斯",再喂给下一层。CodeGPT 每过一个 Block 就 LayerNorm 两次(attn 前 + mlp 前),整条 12 层网络反复在把激活"重新拍回高斯轨道"。
(c) GELU 激活函数:高斯 CDF 当门控
# model.py:81
self.gelu = nn.GELU()
GELU 的定义就是 $\text{GELU}(x) = x \cdot \Phi(x)$,$\Phi$ 字面是标准正态分布的累积分布函数(CDF):
import torch, math
def gelu(x):
cdf = 0.5 * (1.0 + torch.erf(x / math.sqrt(2.0))) # 标准正态的 CDF
return x * cdf
输入 $x$ 越大,$\Phi(x)$ 越接近 1,门完全打开;$x$ 越负,$\Phi(x)$ 越接近 0,门关上。激活函数本身就由一条钟形曲线积分出来——把"高斯"嵌进非线性单元的极致写法。
(d) 扩展词表时新加的行(model.py:370, 377)
nn.init.normal_(new_wte.weight.data[old_vocab_size:], mean=0.0, std=0.02)
nn.init.normal_(new_lm_head.weight.data[old_vocab_size:], mean=0.0, std=0.02)
GPT-2 词表 50257 扩到 CodeGPT 的 50304,多出来的几十行(FIM token、code_start/end、16 个语言 token……)也是从高斯里抽出来的。
这四处加在一起就是"高斯统治内部装修"的全部具体含义:初始化是高斯样本、LayerNorm 把激活拍成高斯、GELU 用高斯 CDF 做门——网络内部所有"形状"都被钟形曲线规范着。
13.3 中心极限定理:为什么"加起来就高斯"
nn.Linear 干的事就一行:
y_i = sum(W[i, j] * x[j] for j in range(n)) # n 项加权求和
这正是 CLT 的标准形式:很多个独立同分布的随机变量加在一起,结果趋近高斯——不论原始分布长什么样。
动手实验可以验证:
import torch
n = 768 # 隐藏维度
ys = []
for _ in range(10000):
x = torch.bernoulli(torch.full((n,), 0.5)) # x 是 768 个 0/1(伯努利,完全不像高斯)
W = torch.randn(n) * (1.0 / n**0.5) # W 是高斯
ys.append((W @ x).item())
# 把 ys 画成直方图 -> 一条标准的钟形曲线
x 是非常不像高斯的伯努利分布(要么 0 要么 1),但 y = sum(W[j] * x[j]) 这种"很多项加权求和"的结果——直方图画出来就是钟形曲线。
CLT 在大模型里到处显灵:
- 每个隐藏单元 = n 项加权求和:n=768,标准 CLT 配方。无论输入来自哪种奇形怪状的分布,输出都接近高斯。
- mini-batch 梯度 = batch 个样本梯度的均值:batch=64 时,平均梯度是 64 个独立梯度的平均——CLT 告诉你这个平均值的分布近似高斯,方差按 1/batch 缩小。这是"为什么 SGD 噪声常被建模为高斯"的根。
- 多头注意力 = 多个头的输出拼接再线性投影:又是一次"很多项相加"。
只要你看到神经网络里到处都是 $\sum$,你就看到了 CLT 的工作场所。所以"内部装修是高斯"不是奇技淫巧——是数学不可避免的结果。
进一步:std=0.02 就是 CLT 的算账
为什么初始化的标准差恰好是 0.02?因为我们希望 $y = Wx$ 之后 $y$ 的方差还跟 $x$ 差不多:
$$\text{Var}(y_i) = \text{Var}\Big(\sum_j W_{ij} x_j\Big) = n \cdot \text{Var}(W) \cdot \text{Var}(x)$$
要让 $\text{Var}(y) \approx \text{Var}(x)$,必须 $\text{Var}(W) \approx 1/n$。GPT-2 / CodeGPT 取 std=0.02,所以 $\text{Var}(W)=0.0004$;当 n=768 时 $n \cdot \text{Var}(W) \approx 0.31$,再配合 LayerNorm 拉回 1,整条 12 层堆叠下来方差不会爆炸也不会消失。残差投影那行 std=0.02 / math.sqrt(2 * config.n_layer)(model.py:159)则更精细:12 层网络会被加 24 次(每层 attn + mlp 各一次),所以单次贡献再除以 $\sqrt{2N}$ 让总方差仍然有界。
整段代码里所有的 0.02 都是 CLT 的算账结果——这也是 Xavier / Kaiming 初始化的全部哲学:用 CLT 算清"加和后方差守恒"。
13.4 高斯的另外两个"必然"理由
CLT 只是高斯统治内部装修的第一个理由。还有两个,一句话各能说清:
最大熵原理:方差给定时高斯最"诚实"
数学上能证明:只知道一个量的均值和方差时,熵最大的连续分布就是高斯。任何其他分布都偷偷加入了你没说过的额外信息("它一定是正的"、"它一定是 0/1"、"它一定有上下界"……)。所以模型初始化只规定 mean=0、std=0.02,剩下交给"最大熵"的高斯——它是信息论意义上的"最诚实的不知道"。
计算性质:高斯是矩阵乘的"不动点"
- 高斯 + 高斯 = 高斯(独立同分布相加,方差直接相加)。
- 矩阵乘高斯 = 高斯(线性变换保持高斯性,只改变协方差矩阵)。
- 边缘化、条件分布、KL 散度、最大似然——全部有闭式解。
Var(Wx) = W · Var(x) · Wᵀ 这种公式只有高斯给得起。换成均匀、伯努利、Beta、Gamma 或者别的什么分布,矩阵乘之后形状是什么都不一定有名字。整套深度学习的方差分析、初始化、归一化的数学,全部建立在"输入是高斯、所以输出还是高斯"的递推上。
把三条理由叠在一起:
CLT → "很多项相加自动是高斯" → 神经网络内部到处都在加。 最大熵 → "只规定方差时高斯最诚实" → 初始化的默认选择。 闭式解 → "矩阵乘对高斯保持封闭" → 整套数学链条算得动。
没有别的连续分布同时满足这三条。这就是为什么大模型内部"非高斯不可"。
13.5 那些刻意不用高斯的地方
但大模型不是高斯的奴隶——下面这些位置必然不是高斯,每一处的选择都有具体理由:
| 位置 | 用了什么 | 为什么不是高斯 |
|---|---|---|
token 输出(F.softmax,model.py:301) |
类别分布 | token 是离散符号,连续高斯铺不到整数格点上 |
dropout(nn.Dropout) |
伯努利 | 需要"开/关"两态,不是连续噪声 |
attention causal mask(model.py:67) |
硬 0/1 mask | 因果约束是确定性的"未来不可见",非概率 |
FIM 触发(tokenizer.py,50% 概率应用 FIM 变换) |
伯努利 | "做不做 FIM 变换"是二元开关 |
FIM 切点(np.random.randint) |
均匀 | 任何位置都该被等概率选中,无偏 |
| token 频率(语料里实际出现的) | 幂律 / Zipf | 自然语言天然的幂律,跟模型设计无关 |
| 训练样本 shuffle | 均匀 | 每条样本被选的概率应当相等 |
这张表的意义可以浓缩成一句话:
离散决策(token、开关、index 选择)从来不是高斯,连续状态(参数、激活、噪声)才是高斯。两者互不替代,各司其职。
13.6 一段话收束
为什么处处是高斯:神经网络内部处处在做"很多项加和"——CLT 让加和自动趋向高斯;只规定均值方差时,最大熵原理选高斯;矩阵乘对高斯保持闭式可计算——三条理由叠在一起,数学上没有别的连续分布能同时替代它去当"内部装修"。
为什么不是只用高斯:输出 token 离散、dropout 是二元开关、随机切点要无偏、token 频率天然是幂律——每个位置都按它本身的需求选分布。高斯只是被叫得最多、负责最重的那一个,而不是唯一。
回到 §1 那张拼图:
输出端是 §3 的 50304 根柱子(类别分布);内部装修是高斯(CLT + 最大熵 + 闭式解);开关与切点是伯努利或均匀。大模型不是只用高斯,而是把每种分布放在它最擅长的位置上。
14. 通俗收束:水池模型 + 折纸模型
最后留两个完全脱离公式的画面,回去随时套:
14.1 概率 = 一池水
想象有一池总量恒定为 1 的水,可以分到无数个杯子里。杯子越多越窄就是连续分布;杯子有限就是离散分布。
- MNIST:10 个杯子,分别贴标签 0~9。一张图片来了,水按"模型觉得是哪个数字"的把握倒进对应杯子。哪个杯子最满,就是预测的数字。
- CodeGPT:50304 个杯子,每个贴一个 token 名字。一段 prompt 来了,水按"下一个该是谁"的概率倒进对应杯子。抽样就是闭眼按水量随机舀一勺,水越多越容易被舀到;argmax 就是直接去喝最满的那杯。
softmax:把任意一组数字当作"杯子的开口大小",水按开口比例自动分配。cross-entropy:每次有人指着"应该装最多水"的那个杯子,看你实际装了多少。装少了就受罚。temperature:调节水的"流动性"。$T$ 大水到处流(分布平),$T$ 小水都流到最低洼的杯子(分布尖),$T = 0$ 时水全部冻在最低的那个杯子里。top-p:从最满的杯子开始倒水盛起来,倒到刚好 90% 总量为止——剩下的一律不喝。
整个训练过程就是让模型学会怎么把水倒得越来越准。
14.2 矩阵 = 折纸
想象有一张无限大的纸(向量空间)。矩阵是一种折纸/拉伸/旋转的动作:你拿起这张纸,按一个矩阵的规则把每一个点搬到新位置。
wte:先把 50304 个离散 token 钉到 768 维纸上的固定坐标——这就是"语义空间"的初始布局。- 12 个 Block:连续做 24 次小的折纸动作,把这些坐标一步步搬到"语义清晰"的最终位置。
lm_head:再把 768 维的纸投影回 50304 维的"得分纸",每个位置的得分代表那个 token 当前的优先级。softmax:最后把这张得分纸折叠归一化成一块面积 = 1 的分布。
训练就是反复调整每一次折纸的角度,让最后那块面积总能在正确答案的位置上隆起一个尖。
总结
回到开头那张拼图:
| 拼图 | 关键操作 | 在 model.py 哪里看 |
|---|---|---|
| 映射 | nn.Embedding(id → 向量) |
model.py:145 wte |
| 映射 | nn.Linear(Q/K/V/MLP 投影) |
model.py:36-37, model.py:80-82 |
| 映射 | 多层 Block 复合 | model.py:186-187 |
| 映射 | lm_head(向量 → 词表 logit) |
model.py:151 |
| 面积 | softmax(logits → 概率分布) | model.py:68, model.py:301 |
| 面积 | cross-entropy(正确 token 的面积) | model.py:192 |
| 面积 | temperature / top-k / top-p | model.py:279-299 |
| 面积 | multinomial 抽样 | model.py:302 |
下次再看到 $\mathrm{softmax}(QK^\top/\sqrt d)V$ 之类的式子,把它读成:
"把 $x$ 做三次映射得到 Q、K、V;Q·Kᵀ 算一组打分;softmax 把打分捏成面积;按面积比例从 V 里抽加权平均。"
只要这两个直觉牢靠了——概率是面积,矩阵是映射——剩下的 Transformer、GPT、attention、生成、训练,都只是这两件事在不同尺度上的反复拼装。MNIST 是一次 10 类的最小演示,GPT 是 50304 类、几千亿次复用的极致放大——但底层那块直方图、那一串 nn.Linear,从未变过。