bobofamilyNIU CHUN
10/36

3481 个参数:数值一样,不等于可以互换

2026-09-0619 分钟—阅读

上一篇把 3 个字符变成了 6 个数。剩下的路听起来没什么新东西:加权求和第一篇讲过,交叉熵第七篇讲过。

但真正的新东西有两个,而且长成同一个样子 —— 两种写法算出完全相同的数,代价却差着一个量级。前一处是把 3 个向量拼成 6 个数,后一处是算 loss。

整条链路先摆出来

emb = C[X] # (N, 3, 2) 上一篇
h = torch.tanh(emb.view(-1, 6) @ W1 + b1) # (N, 100) 本篇
logits = h @ W2 + b2 # (N, 27) 本篇
loss = F.cross_entropy(logits, Y) # 标量 本篇
名字形状是什么
X(N, 3)每行 3 个字符编号,如 [5,13,13]
emb(N, 3, 2)每个字符换成 2 个数
emb.view(-1, 6)(N, 6)每个样本压成一条 6 维向量
h(N, 100)隐藏层输出
logits(N, 27)27 个未归一化的分数
一个样本走完整条链路。五步里没有一处是新数学 —— 新的只有 embedding 那一层,和全程要盯住的形状。

从这里开始,读代码的主要工作是盯形状。

隐藏层:就是第一篇那个公式

zⱼ = Σₖ wⱼₖ aₖ + bⱼ aⱼ = σ(zⱼ)

h = torch.tanh(emb.view(-1, 6) @ W1 + b1) 和它是同一件事。逐项对:

第一篇的记号本篇的代码说明
aₖemb.view(-1,6) 的一行上一层激活值,这里是 6 维
wⱼₖW1[k][j]下标顺序是反的,见下
Σₖ@求和被矩阵乘法吞掉了
bⱼb1[j]广播到每一行
σtanh第一篇用 sigmoid,换了个非线性而已
aⱼh[:, j]这一层的激活值

三个版本的同一件事:

阶段形态一次算多少
第 1–3 篇数学公式,手推1 个神经元
第 4–6 篇(micrograd)Neuron / Layer / MLP 类,Python for 循环1 个神经元,标量
本篇一次矩阵乘100 个神经元 × N 个样本

第五篇那个 Neuron.__call__ 就是 sum(wi*xi) + b 再过 tanh —— 一个 for 循环。本篇的 @ 把那个 for 循环换成了一次库调用。数学一个字没变,变的是它一次算多少个。

一次算 100 个,但每个神经元算的是自己那一份。下面这个动画可以逐个看过去 —— 注意左边 6 个输入始终不动,变的只有连线上的权重:

交互动画 · 100 个神经元 · 可拖动 / 自动扫描新窗口打开 ↗

转置约定:为什么 W1 是 (6, 100)

这是记号上的坑,不是理解上的。 第一篇写 wⱼₖ,j 是输出、k 是输入,矩阵是 (输出, 输入);这里 W1 是 (输入, 输出) = (6, 100),正好反过来 —— 因为写成 x @ W1 时样本摆在左边,形状必须是 (N,6) @ (6,100)。

顺带:PyTorch 的 nn.Linear 内部存的是 (out, in),前向时做 x @ W.T,和第一篇一致。以后看到 (out, in) 别以为自己记反了 —— 两种约定都在用,看它前向怎么乘就知道是哪种。

为什么必须换成矩阵乘

本篇要算 228146 个样本 × 100 个神经元 × 6 个乘法 ≈ 1.37 亿次乘加。micrograd 那种标量 for 循环跑不动,只有矩阵乘一条路。

而矩阵乘要求形状严丝合缝。于是:

想算得快 ⟹ 用矩阵乘 ⟹ 必须会摆弄形状 ⟹ view / stride / contiguous

这就是下面花大篇幅讲内存布局的原因。 不是 PyTorch 的 API 琐碎,而是「把逐个神经元的循环换成一次矩阵运算」必然要付的代价。

tanh 不是装饰

没有非线性时 (x @ W1 + b1) @ W2 + b2 展开就是 x @ (W1 @ W2) + (...),两层线性层等价于一层,加再多层都没用。非线性是「深度」这件事成立的前提。

V4 的伏笔。 W1 = torch.randn(...) 是标准正态,6 个输入各乘一个标准正态权重再加偏置,送进 tanh 之前的标准差约 2.6 —— 于是 h 里大约一半的数会贴在 ±1 上。tanh 在饱和处几乎是平的,梯度接近 0,那些参数学不动。

检查方法:(h.abs() > 0.9).float().mean()。这个数字下一节还会以另一种形式出现。

为什么是拼接,不是求和

一个样本的 emb 是 3 行 2 列,要接成一行 6 个数:

"e" → [0.31, -0.82]
"m" → [1.05, 0.47] ⟹ [0.31, -0.82, 1.05, 0.47, 1.05, 0.47]
"m" → [1.05, 0.47]

数值一个没变、一个没少,只是重新排了形状。两个原因:

原因说明
数学硬要求矩阵乘法要求 (a,b) @ (b,c)。(N,3,2) 是三维,没法乘 (6,100)
语义要求全连接层的每个神经元要同时看到全部上下文再判断

为什么不能用求和或平均来压缩? 那样 abc 和 cba 会算出完全一样的结果,位置信息全丢。拼接保留了顺序 —— W1 的第 0/1 行固定对应第 1 个字符,第 2/3 行对应第 2 个,第 4/5 行对应第 3 个。模型因此知道「哪个字符在哪个位置」,不用额外告诉它。

到 Transformer 这个好处会消失。 attention 天然不区分顺序 —— 对它来说输入是一个集合而不是序列,所以位置信息必须用 positional encoding 专门补回去。现在这个 MLP 不需要,是因为拼接把位置写进了坐标里。

张量是一条内存加一份说明书

一个 PyTorch 张量 = 一条一维的连续内存(storage)+ 一份「怎么读它」的说明书(shape / stride / offset)。

上:18 个格子就是全部。中:三份说明书从同一条内存切出三个张量,data_ptr() 完全相同。下:转置只把说明书对调,读取顺序却开始跳着走。

同一段内存,四种形状

torch.arange(18) 在内存里就是 18 个格子,一条直线:没有行、没有列、没有阶数。「几阶」是张量对象的属性,不是内存的属性。

a = torch.arange(18)
a.view(18) # 1 阶
a.view(3, 6) # 2 阶
a.view(9, 2) # 2 阶
a.view(3, 3, 2) # 3 阶
# 四个张量,data_ptr() 完全相同

唯一的硬约束:各维长度的乘积 = 内存格子总数。3×6 = 18 ✓,4×5 = 20 ✗。

地址 = Σ index[i] × stride[i]

b = a.view(3,6) 的说明书是「每 6 格切一刀,切成 3 段」:

第 0 行 ← 地址 0..5 第 1 行 ← 地址 6..11 第 2 行 ← 地址 12..17

b[1][2] = 跳过 1 整段(每段 6 格)+ 段内走 2 格 = 地址 8。内存里不存在二维结构,「二维索引」只是把 [1][2] 换算成直线上的一个地址。

stride[i] 就是这个换算式里的系数:在第 i 个维度上索引加 1,地址前进几格。行优先(C-order)的推导规则是「最后一维变化最快」:

stride[最后一维] = 1
stride[i] = stride[i+1] × shape[i+1] # 从右往左推
shape推导stride
(3, 6)1 → 1×6(6, 1)
(9, 2)1 → 1×2(2, 1)
(N, 3, 2)1 → 1×2=2 → 2×3=6(6, 2, 1)

emb 的 (6, 2, 1) 读起来非常直白:换到下一个样本跳 6 格,同样本内换到下一个字符跳 2 格,同字符内换到下一个分量跳 1 格。

view 恰好就是要的拼接

emb 在内存里本来就是这样排的:

样本 0: [0.31][-0.82][1.05][0.47][1.05][0.47] | 样本 1: ...
← 字符1 → ← 字符2 → ← 字符3 →

这条内存已经是「每 6 个数一个样本、按字符顺序排好」的了。 想要的 (N,6) 的 stride 是 (6,1) —— 跳到下一个样本仍是 6 格,读取顺序完全没变。

emb.view(emb.shape[0], -1) # 零成本,只改说明书
torch.cat([...], dim=1) # 结果一样,但要新开一块内存搬数据

验证「view 真的没搬数据」最直接的实验不是对比 data_ptr(),而是改一个值:

a = torch.arange(18)
b = a.view(3, 6)
b[0, 0] = 999
print(a[0]) # 999 —— a 也变了

两个张量指向同一段 storage。前向传播里一般不会原地改张量,但调试时手动改了某个值,要知道它可能连带改了别处。.clone() 才是真的拷贝。

不是所有拼接都这么幸运。如果想按「所有样本的第 1 个字符排一起」,内存顺序对不上,就只能老实复制。这里能零成本,是因为想要的顺序恰好就是已有的顺序。

转置之后为什么不能 view

转置也只交换说明书,内存一格没动:

b = a.view(3, 6) # shape (3,6) stride (6,1)
d = b.t() # shape (6,3) stride (1,6) ← 两个字段各自对调

连续(contiguous)的定义:按张量自己的逻辑顺序(最后一维变化最快)把元素念一遍,如果地址正好是 0,1,2,3,… 一路不间断,就是连续的。

张量念出来的地址序列连续?
b0,1,2,3,…,17✓
d0,6,12,1,7,13,2,8,14,…✗

d.view(18) 要求给出一个一维张量,元素顺序是 0,6,12,1,7,…。而一维张量只有一个 stride 数字 s,地址公式是 i × s:

i=1 要落到地址 6 → s = 6
i=2 要落到地址 12 → s = 6 ✓
i=3 要落到地址 1 → 需要 3×6 = 1,无解 ✗

跳来跳去的顺序,没法用「每步固定跳几格」表达。 所以它报错:

view size is not compatible with input tensor's size and stride

「不连续」不是「内存有空洞」。 d 用的还是那 18 个格子,一个不多一个不少。不连续说的是读取顺序和存储顺序对不上。

view / reshape / contiguous,以及 -1 写在哪

方法行为何时用
.view()只改说明书,不连续就报错确信连续,想让异常暴露出来
.reshape()能 view 就 view,否则自动复制,永不报错日常图省事
.contiguous()强制新开内存复制成连续明确知道要付这个代价

Karpathy 坚持用 view,是想让「这里是零成本的」这件事显性化。工程代码里 reshape 更常见。

至于 -1:

emb.view(228146, 6) # ✗ 两个数都写死
emb.view(-1, 6) # △ N 灵活了,6 还是写死
emb.view(emb.shape[0], -1) # ✓ 两边都不写死

-1 是占位符,意思是「这一维你自己推」:未知维 = 总元素数 ÷ 其他维度的乘积。一个 view 里最多只能有一个 -1 —— 两个未知数一个方程,无解。

写死会在两处崩掉。 一处是 mini-batch:N 从 228146 变成 32;另一处是调参时把 embedding 维度从 2 改成 10,那个 6 就该变成 30。

后者更阴险 —— view(-1, 6) 不报错,只是给出错误的形状(把不同样本的数字混进同一行),等到 @ W1 才炸。错在 A 处、报在 B 处。

参数、输入、激活值:谁会被更新

这是最容易混的一处。

东西形状是什么会变吗
W1, b1(6,100), (100,)参数,100 个神经元各自的权重和偏置训练时更新
emb.view(N,-1)(N, 6)输入数据,不变
h(N, 100)激活值,每个样本过每个神经元的输出每次前向重算
上面一行的每个张量都带 N,样本越多它们越高;参数完全不在这条线上。

一个神经元有几个权重?6 个,不是 3 个。 3 是「几个字符」,但每个字符已经不是一个数了,它是一个 2 维向量 —— 权重数 = block_size × n_embd = 3 × 2 = 6。

逐级放大:

神经元样本结果
1 个1 个1 个数
100 个1 个(100,)
100 个N 个(N, 100)
(N, 6) @ (6, 100) = (N, 100)
└── 6 被消掉了 ──┘

中间那个 6 是求和的方向。矩阵乘法里消掉的总是「特征」,留下的是「样本」和「神经元」。

参数总量与 N 无关

权重: 100 × 6 = 600
偏置: 100 × 1 = 100
────────────────────
共 700 个参数

喂 1 个样本是 700 个参数,喂 100 万个还是 700 个。 同一套参数处理所有样本 —— 这是神经网络能工作的前提。h 有 N 行,是因为它是结果,不是参数。

神经元个数不是层数

隐藏层有 100 个神经元,但这是 1 层,不是 100 层。

数什么怎么数本篇的值
深度(层数)做了几次 @W + b2 层(@W1+b1、@W2+b2)
宽度(神经元数)那一层输出几个数隐藏层 100,输出层 27
高亮的六条线就是 W1 的第一列 —— 一个神经元的全部权重。一百个神经元并排在同一列上,这是 1 层。

两者是独立的超参数。对照:GPT-2 是 12 层、宽 768;GPT-3 是 96 层、宽 12288。本篇只有 2 层 —— 后来十年的进展,主要发生在深度上。

第五篇那个 MLP(6, [100, 27]) 的写法最直观:列表里两个元素,就是两层。

C 也是参数

parameters = [C, W1, b1, W2, b2]

这件事容易忽略:反向传播时梯度会一路传回 C,调整每个字符的向量位置。上一篇说「谁挨着谁是梯度推出来的」,推它的就是这条路径。

对照 micrograd:那时候参数只有权重和偏置。本篇多了一类参数 —— 输入的表示本身也在学。

输出层:两个维度都不自由

套上面那个模板:每层权重都是 (上一层宽度, 这一层宽度),偏置是 (这一层宽度,)。

隐藏层输出层
输入几个数6100(h 的宽度)
这层几个神经元100(自由选)27(被词表锁死)
权重W1 (6,100)W2 (100,27)
偏置b1 (100,)b2 (27,)

隐藏层那个 100 是随便定的;输出层必须正好吐出 27 个数,两个维度都不自由。

这一层也没有 tanh:tanh 会把值压进 (-1, 1),而 logits 是未归一化的对数概率,需要能取任意实数(很可能时 +15,很不可能时 −20)。压到 ±1 就表达不了这种差距。归一化交给 softmax。

W2 的第 k 列是「字符 k 的检测器」

logitₖ = Σⱼ W2[j][k] × hⱼ + b2[k] j 从 0 到 99

W2[:, 1] 那 100 个数就是「a 的检测器」,它在问:这 100 个特征的这种组合,有多像字母 a?W2[7][1] 是大正数就意味着「第 7 号特征越强,越应该输出 a」,是大负数则相反。

b2[k] 的直觉特别好懂:它是字符 k 的固有偏好 —— 不看任何上下文,这个字符本身有多容易出现。极端情况:如果 h 全是 0,那么 logits = b2,模型只能靠 b2 猜,训练会把它推向字母的自然频率。

参数大头在这里

个数
C (27×2)54
W1 + b1700
W2 + b22727
合计3481

输出层一层就占了 78%。真实语言模型词表几万起步,输出层会变成绝对的参数大户 —— 后来才有共享嵌入、分层 softmax 这些技巧。

初始 loss 不是 3.30,是 17

健全性基准还是那个:完全均匀地猜 27 个字符,ln 27 = 3.2958。

但 randn 初始化下实际跑出来的初始 loss 是 17 左右,比瞎猜差五倍多。反推一下:e⁻¹⁷ ≈ 4.1×10⁻⁸,模型给正确答案的概率大约是两千四百万分之一。它不是在瞎猜,是在非常自信地给出错误答案。

和第八篇对账。 第八篇说「初始 loss 应该在 3.30 附近,实际略高,约 3.5—4」,这里却是 17。两个都对,差别在网络深了一层。

第八篇是单层:logits = onehot @ W 直接就是 W 的一行,27 个标准正态数,标准差 1.0。本篇是两层:h 里 100 个数各乘一个标准正态权重再相加,logits 的标准差冲到 9 左右。

loss 大致就是「最大的那个 logit 领先正确项多少」,而这个差距随标准差线性放大 —— 标准差从 1 涨到 9,loss 就从 3.8 涨到 18 上下。这不是 bug,是初始化没管住尺度,和上面 tanh 饱和是同一个病。

记住 3.2958,它是判断初始化好不好的标尺。V4 一整集在做这件事,第一步就是把 W2 乘个 0.01。

为什么必须用 F.cross_entropy

手写版本第七、八篇已经写过:

counts = logits.exp() # (N,27) 全变正数
prob = counts / counts.sum(1, keepdims=True) # (N,27) 每行归一化
loss = -prob[torch.arange(N), Y].log().mean() # 标量

三行都不新,逐行的解释在第七篇「其余 26 项去哪了」和第八篇「loss 怎么算」里。

换成 F.cross_entropy(logits, Y) 不是为了省这三行 —— 是因为这三行会算出 nan。

它就是手写那六步,加了第 0 步

步骤手写的F.cross_entropy 内部
0没有logits -= logits.max(1, keepdim=True)
1logits.exp()同
2counts / counts.sum(1, keepdims=True)同
3.log()同(1–3 合并成 log_softmax)
4prob[arange(N), Y]同
5–6.mean()、取负同

只有第 0 步是你没写的,也正是它防住 nan。其余六步一模一样,所以两边数值才相等。

缩到 4 个类别手算一遍。输入 logits = [2.0, 1.0, 3.0, 0.0],正确答案 Y = 2:

第 0 步 减 max(3.0) → [-1.0, -2.0, 0.0, -3.0]
第 1 步 exp → [0.3679, 0.1353, 1.0000, 0.0498]
第 2 步 归一化 → [0.2369, 0.0871, 0.6439, 0.0321] ← 加起来 = 1
第 3 步 log(0.6439) → -0.4402
第 4 步 Y=2,取索引 2 → -0.4402 (其余三个根本不参与)
第 5-6 步 取负、求平均 → 0.4402
F.cross_entropy(torch.tensor([[2., 1., 3., 0.]]), torch.tensor([2]))
# tensor(0.4402) ✓ 一致

三处差别,只有一处影响结果

#差别影响结果吗什么时候能感觉到
1减 max✓ 会(防 nan)logits 变大时
2前向融合,不落中间张量✗ 不会内存和速度
3反向用化简式✗ 不会训练速度

「数值一样」和「可以互换」是两回事。 类比:sum(range(1, n+1)) 和 n*(n+1)//2 结果一样,但后者不是前者的「同一个实现」。

差别一:不减 max 就是 nan

logits = torch.tensor([-100., -3., 0., 100.])
prob = logits.exp() / logits.exp().sum()
print(prob) # tensor([0., 0., 0., nan]) ← 炸了
exp(-100) ≈ 3.7e-44 下溢成 0,无害
exp(-3) = 0.0498
exp(0) = 1.0
exp(100) = inf ← 上溢(float32 上限 ≈ 3.4e38)
然后 inf / inf = nan

解法是 softmax 的平移不变性:给所有 logits 同时加一个常数 c,分子分母各多出一个 e^c,约掉,结果不变。

e^(zᵢ+c) / Σⱼ e^(zⱼ+c) = (e^c · e^zᵢ) / (e^c · Σⱼ e^zⱼ) = e^zᵢ / Σⱼ e^zⱼ

所以减去每行最大值是免费的:最大的那个变成 0,exp(0) = 1,其余全小于 1 —— 永远不会上溢。

logits - logits.max() = [-200, -103, -100, 0]
→ prob = [0, 1.4e-45, 3.8e-44, 1.0] ✓ 干净

上溢和下溢不对称。 负方向再大只是变 0,答案还是对的;正方向一超就是 inf → nan,全盘皆输。所以只需要防上溢。

门槛是 ln(3.4e38) ≈ 88.7。而本篇初始 loss 是 17、logits 本来就偏大,训练开始后还会继续往两头拉开 —— 手写版是真的会炸,不是理论风险。

差别二和三:内存与速度

手写版每一步都新建一个 (N,27) 张量,而且反向要用,不能立刻释放:

词表一个中间张量 (N × V × 4 字节)三四个加起来
27(本篇)24.6 MB约 100 MB
50257(GPT-2)45.6 GB直接爆显存

27 个类别看着无所谓。真实词表五万起步时,同样的 N 就是 45 GB —— 这不是优化,是能不能跑起来的问题。

反向那一处:手写版要沿 exp → sum → div → log → mean 五个节点倒推,但 softmax 和交叉熵合起来求导能化简成一行:

∂L/∂logits = (prob − onehot(y)) / N

用上面那个手算例子(prob = [0.2369, 0.0871, 0.6439, 0.0321],Y = 2):

prob = [0.2369, 0.0871, 0.6439, 0.0321]
onehot = [0, 0, 1, 0 ]
─────────────────────────────────────────
梯度 = [0.2369, 0.0871, -0.3561, 0.0321]

读起来非常直白:正确答案那项梯度为负 → 推高它的 logit;其余项梯度为正,模型给的概率越高推得越狠 → 压低它们;prob 恰好等于 onehot 时梯度全 0 → 学到头了。

PyTorch 直接算这一行,根本不倒推那五步。V5 那一集要你亲手推这个化简 —— softmax + 交叉熵之所以到处都在用,很大一部分原因就是它的导数长得这么好看。

两个签名坑

一、传 logits,不是 prob。 F.cross_entropy 内部会做 softmax,你已经 softmax 过一次再传进去就等于做了两次。不报错,只是 loss 降不下去,非常难查。

二、Y 是类别编号,不是 one-hot。 直接给索引 torch.tensor([2]) 就行 —— 反正 27 项里 26 项要乘 0,白造。

parameters 那个列表是个报警器

parameters = [C, W1, b1, W2, b2]
print(sum(p.nelement() for p in parameters)) # 3481
for p in parameters:
p.requires_grad = True

它不是 PyTorch 关键字,就是个普通 Python list。 因为训练时有三件事要对每个参数都做一遍:

for p in parameters: p.requires_grad = True # 一次性
for p in parameters: p.grad = None # 每轮清空
for p in parameters: p.data += -lr * p.grad # 每轮更新

不装起来就得每处写五行。真实模型有几百组参数,漏一个那个参数就永远不更新,而且不报错。

那行 print 是个报警器:改 embedding 维度时如果忘了同步改 W1 的行数,这个数字会不对 —— 在跑训练之前就发现。列表里存的是引用不是拷贝,所以 p.data += ... 改的就是 W1 本体。

对照 PyTorch 官方写法:

optimizer = torch.optim.SGD(parameters, lr=0.1)
optimizer.zero_grad() # 替代 p.grad = None 的循环
loss.backward()
optimizer.step() # 替代 p.data += 的循环

Karpathy 手写这个列表,就是为了让你看见 optimizer 背后没有魔法 —— 它做的就是这两个 for 循环。和 micrograd 一致:第五篇那个 MLP.parameters() 也是手动返回所有 Value 的列表,本篇只是把标量换成了张量。


一句话概括这一篇:3481 个参数、2 层、形状全部对上了 —— 但它一次都还没训练过。 初始 loss 是 17,比瞎猜还差五倍。

V3 的后半段要做四件事:mini-batch 把一轮的 1.37 亿次乘加降到几万次(顺带会把 view(-1, 6) 里写死的那个 6 炸出来);学习率不再靠猜,而是扫一遍找出来;数据集第一次拆成训练 / 验证 / 测试三份 —— 第八篇说过「验证 loss 触底反弹的那一刻就该停」,到那时才真的用得上;最后是调参,以及把那 2 维嵌入画出来看看它到底学到了什么。loss 从 2.45 降到 2.2 这件事,会发生在那里。

至于初始 loss 那个 17,和 h 里一大堆贴着 ±1 的饱和值,是同一个病 —— 要等到 V4 专门处理初始化时才一并解决。