3481 个参数:数值一样,不等于可以互换
上一篇把 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 个未归一化的分数 |
从这里开始,读代码的主要工作是盯形状。
隐藏层:就是第一篇那个公式
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 个输入始终不动,变的只有连线上的权重:
转置约定:为什么 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)。
同一段内存,四种形状
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..17b[1][2] = 跳过 1 整段(每段 6 格)+ 段内走 2 格 = 地址 8。内存里不存在二维结构,「二维索引」只是把 [1][2] 换算成直线上的一个地址。
stride[i] 就是这个换算式里的系数:在第 i 个维度上索引加 1,地址前进几格。行优先(C-order)的推导规则是「最后一维变化最快」:
stride[最后一维] = 1stride[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] = 999print(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,… 一路不间断,就是连续的。
| 张量 | 念出来的地址序列 | 连续? |
|---|---|---|
b | 0,1,2,3,…,17 | ✓ |
d | 0,6,12,1,7,13,2,8,14,… | ✗ |
d.view(18) 要求给出一个一维张量,元素顺序是 0,6,12,1,7,…。而一维张量只有一个 stride 数字 s,地址公式是 i × s:
i=1 要落到地址 6 → s = 6i=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) | 激活值,每个样本过每个神经元的输出 | 每次前向重算 |
一个神经元有几个权重?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 + b | 2 层(@W1+b1、@W2+b2) |
| 宽度(神经元数) | 那一层输出几个数 | 隐藏层 100,输出层 27 |
两者是独立的超参数。对照:GPT-2 是 12 层、宽 768;GPT-3 是 96 层、宽 12288。本篇只有 2 层 —— 后来十年的进展,主要发生在深度上。
第五篇那个 MLP(6, [100, 27]) 的写法最直观:列表里两个元素,就是两层。
C 也是参数
parameters = [C, W1, b1, W2, b2]这件事容易忽略:反向传播时梯度会一路传回 C,调整每个字符的向量位置。上一篇说「谁挨着谁是梯度推出来的」,推它的就是这条路径。
对照 micrograd:那时候参数只有权重和偏置。本篇多了一类参数 —— 输入的表示本身也在学。
输出层:两个维度都不自由
套上面那个模板:每层权重都是 (上一层宽度, 这一层宽度),偏置是 (这一层宽度,)。
| 隐藏层 | 输出层 | |
|---|---|---|
| 输入几个数 | 6 | 100(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 到 99W2[:, 1] 那 100 个数就是「a 的检测器」,它在问:这 100 个特征的这种组合,有多像字母 a?W2[7][1] 是大正数就意味着「第 7 号特征越强,越应该输出 a」,是大负数则相反。
b2[k] 的直觉特别好懂:它是字符 k 的固有偏好 —— 不看任何上下文,这个字符本身有多容易出现。极端情况:如果 h 全是 0,那么 logits = b2,模型只能靠 b2 猜,训练会把它推向字母的自然频率。
参数大头在这里
| 个数 | |
|---|---|
C (27×2) | 54 |
W1 + b1 | 700 |
W2 + b2 | 2727 |
| 合计 | 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) |
| 1 | logits.exp() | 同 |
| 2 | counts / counts.sum(1, keepdims=True) | 同 |
| 3 | .log() | 同(1–3 合并成 log_softmax) |
| 4 | prob[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.4402F.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.0498exp(0) = 1.0exp(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 专门处理初始化时才一并解决。