概率就是面积,矩阵就是映射:大模型最底层的两块拼图

2026-05-06 · Steve Chan

概率就是面积,矩阵就是映射:大模型最底层的两块拼图

一个常见的疑惑是:为什么大模型论文动不动就是 $P(y\mid x)$、$\arg\max W x$、$\mathrm{softmax}(QK^\top/\sqrt d)$?这些符号背后到底在做什么直觉上的事情?

这篇文档只讲两件事:

  • 概率就是面积——所有 softmaxcross_entropytop-ptemperature 都是在切、推、压一块面积为 1 的"蛋糕"。
  • 矩阵就是映射——所有 nn.Linearnn.EmbeddingQ/K/V 投影、lm_head 都是把一个向量搬运、旋转、拉伸到另一个向量。

把这两个直觉钉牢,再看 model.py:177-198forward() 就只剩一句话:先一连串映射把 token id 变成一个向量,再把那个向量映射成 logits,最后 softmax 把 logits 摆成一块面积,从面积里抽样下一个 token


目录

  1. 一句话拼图:forward 就是 "映射 → 面积"
  2. 概率就是面积:从直方图说起
  3. MNIST:把"概率分布"和"下一个 token"画给你看
  4. softmax:把任意 logits 折成"总面积 = 1"
  5. cross-entropy:你在正确答案上摆了多少面积
  6. temperature / top-k / top-p:三种切蛋糕的方法
  7. 矩阵就是映射:从 y = Wx 开始
  8. Embedding 是查表,底层是矩阵乘
  9. Q、K、V:同一个向量被三种映射"分成三份"
  10. lm_head:从语义空间映回词表空间
  11. 多层堆叠 = 映射的复合
  12. 训练时发生了什么:面积形状被往正确答案上拉
  13. 大模型用了几种概率分布?为什么处处是高斯
  14. 通俗收束:水池模型 + 折纸模型

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 = 50304model.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,每次回答略有不同。

温度 = 0multinomial 退化成 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_entropymodel.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.pyapply_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

注意这里两块拼图同时出现

  1. Q、K、V 是三次矩阵映射——把 $x$ 搬到三个不同的子空间。
  2. $Q \cdot K^\top$ 是一组打分,softmax 后变成面积 = 1 的分布——给每一个 V 应该被取多少作出概率回答。
  3. 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()                   # 更新所有矩阵的参数

发生的事情是:

  1. 前向:一连串矩阵乘把 $X$ 搬到 logits,softmax 后得到一块面积;cross-entropy 度量"正确 token 那一格的面积有多小"。
  2. 反向:对每个矩阵元素求导——"如果我把这个矩阵元素调大 0.0001,正确答案那一格的面积会涨多少?"
  3. 梯度下降:每个矩阵元素都按"涨多少"的方向被调整一点点。

学习的实质:所有矩阵(wteW_QW_KW_Vc_projc_fclm_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)、LayerNormmodel.py:28)、GELUmodel.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.Linearnn.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.softmaxmodel.py:301 类别分布 token 是离散符号,连续高斯铺不到整数格点上
dropoutnn.Dropout 伯努利 需要"开/关"两态,不是连续噪声
attention causal maskmodel.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,从未变过。