看着对的那一行:96 次查表落在 27 行上,和 Ex1 收官
上一篇十三步拿下 1883 个参数,剩下 W1、b1、C 共 2254 个。
这一篇五步走完,没有分叉点 —— 从 hprebn 一路回到嵌入表 C,五步一条直线。
前三步是上一篇的复制粘贴,后两步各有一个前面没出现过的东西。
前向三行:
emb = C[Xb] # (32,3,10) 查表embcat = emb.view(emb.shape[0], -1) # (32,30) 展平,只重排不算数hprebn = embcat @ W1 + b1 # (32,64) Linear 1❗
Xb是 (32,3),不是一维。 这跟第 19 篇第 ① 步用的Yb (32,)不同 ——C[Xb]一次取 96 个(32×3),而logprobs[range(n), Yb]一次只取 32 个。 这个差别是第 ⑤ 步全部麻烦的来源。
查表把整数变成向量,所以维度加一:
Xb (32, 3) 32 条样本 × 3 个字符编号 ↓ C[Xb] 每个整数换成 10 维向量emb (32, 3, 10) 多出来的 10 就是 n_embd前三步:换个名字而已
前向 hprebn = embcat @ W1 + b1 和上一篇的 logits = h @ W2 + b2 是同一个结构:
dembcat = dhprebn @ W1.T # (32,64) @ (64,30) → (32,30)dW1 = embcat.T @ dhprebn # (30,32) @ (32,64) → (30,64)db1 = dhprebn.sum(0) # (32,64) → (64,)把上一篇那三行的 h 换成 embcat、W2 换成 W1 就得到这三行,一个字不用想。
db1 同样是 sum(0) 且不加 keepdim —— b1 的形状是 (64,),本身就是一维。
第四步:把形状换回去
demb = dembcat.view(emb.shape) # (32,30) → (32,3,10)❗ 先澄清:前向的 view 没有修改 emb
「前向不是 view 之后就拉齐了吗,为什么反向还要还原」—— 拉齐的是 embcat,不是 emb。
view 返回一个新视图,emb 本身还是 (32,3,10)。
第十三篇立的那条铁律在这里派上用场:每个前向变量都有一个同形的梯度。
| 前向变量 | 形状 | 它的梯度 | 形状 |
|---|---|---|---|
emb | (32,3,10) | demb | (32,3,10) |
embcat | (32,30) | dembcat | (32,30) |
两个变量都存在,所以两个梯度都得有,形状各自跟着自己。
局部导数全是 1
前向是「搬家」不是「计算」—— 每个输出位置的值就是某个输入位置的值:
embcat[k][10j + d] = emb[k][j][d]所以:
∂ embcat[k][10j+d] / ∂ emb[k][j][d] = 1一对一,系数 1,其余全 0。 这是形态 A 的极端情况 —— 连乘法都不用做:
dembcat[0] (9,) demb[0] (3,3)[g0, g1, g2, g3, g4, g5, g6, g7, g8] → [g0, g1, g2] [g3, g4, g5] [g6, g7, g8]九个数一个没变,只是重新分组。
💡 传
emb.shape而不是手写view(32,3,10)—— batch size 或 block_size 改了也不会错。 跟第 19 篇第 ① 步用zeros_like而不是torch.zeros(32, 27)是同一个理由。
🔑 形状操作的反向规律
| 前向 | 反向 | 为什么 |
|---|---|---|
view / reshape | view 回去 | 双射,一对一 |
transpose / permute | 逆置换 | 双射,一对一 |
expand / 广播 | sum | 一对多,要收回来 |
sum | 广播 | 多对一,要发出去 |
🔑 判据只有一条:这个操作有没有改变数的个数。
view前后都是 960 个数(32×3×10 = 32×30),个数不变 ⟹ 一对一 ⟹ 反向就是它自己的逆操作。 广播和求和会改变个数,所以一个要sum收回来、一个要复制发出去。这张表把前面两篇的形态 B 和 C 也装进去了:广播的反向是 sum、sum 的反向是广播, 本来就是这条判据的两个特例。
第十篇讲过 view 为什么零成本(共享 storage,只换一份「说明书」),那是前向侧;这里是反向侧。
为什么这一步必须有
前后两步对形状的要求不一样:矩阵乘法只能吃二维,所以前向要摊平; 查表的语义天然是三维,所以反向要还原。
不还原的话,第 ⑤ 步就得自己算列区间:
dC[ix] += dembcat[k, j*10 : j*10+10] # ❌ 要手算偏移,容易错dC[ix] += demb[k, j] # ✅ 下标天然分开第五步:散射累加
同一行 C 可能被取了很多次。 32×3 = 96 次查表,而 C 只有 27 行 ——
平均每行 3.6 次,常见字母(a、e)可能十几次。
Xb = [[0, 5, 13], 样本0 取了 C[0], C[5], C[13] [5, 13, 13], 样本1 取了 C[5], C[13], C[13] ← 一条样本里就取了两次 C[13] ...]数学上:
∂L/∂C[c][d] = Σ ∂L/∂emb[b][k][d] 对所有满足 Xb[b][k] = c 的 (b,k) 求和C 第 c 行的梯度 = 所有「取到了第 c 行」的位置的梯度之和。
dC = torch.zeros_like(C) # 先铺 0,累加器的初值for k in range(Xb.shape[0]): # k 遍历 32 条样本 for j in range(Xb.shape[1]): # j 遍历 3 个位置 ix = Xb[k, j] # 这个位置当初查的是 C 的第几行 dC[ix] += demb[k, j] # 把梯度加回那一行💡
demb[k, j]是个 10 维向量,不是一个数。demb是三维(32,3,10), 给了两个整数下标之后降两维,只剩最后那一维。dC[ix]也是(10,),所以+=成立。
❗ 为什么必须 +=
用赋值的话,dC[13] 最后只剩 demb[1][2] —— 前两次的贡献被覆盖了。
「一个变量被用了 N 次,梯度加 N 次」这条累加律,前面已经用过两次(第 19 篇的 counts、
上一篇的 bndiff),但那两次 N 都是 2,而且写在代码里看得见。
这里 N 是变的 —— 取决于每个字母在这批样本里出现了多少次,运行时才知道。 这是全 Ex1 里唯一一处「路数不是数出来的」。
⚠️ 看着对的那一行
dC[Xb] += demb # ❌ 看起来完全正确,但重复的行只加一次这一行读起来比双重循环干净得多,而且不报错。但它是错的: numpy 和 PyTorch 的花式索引原地加法,重复位置只生效一次。
原因是 dC[Xb] += demb 会被拆成「取出 → 加 → 写回」三步,而不是原子的累加 ——
Xb 里两个位置都指向第 13 行时,两次写回的是同一个基于旧值算出来的结果,后一次盖掉前一次。
向量化的正确写法有两个:
dC.index_add_(0, Xb.view(-1), demb.view(-1, 10)) # 专门处理重复累加dC = F.one_hot(Xb.view(-1), 27).float().T @ demb.view(-1,10) # 或者用 one_hot 转成矩阵乘Karpathy 用双重循环是为了教学清晰 —— 96 次,每次干什么一目了然。
🔑 这是全系列第一处「不报错但算错」的坑。 前面撞过的坑都会报错或者被
cmp抓住:sum的轴写错报形状不匹配、转置位置错了内维对不上、.clone()漏了cmp当场变红。 这一行三样都不触发 —— 形状对、不报错、而且大部分格子的值是对的, 只有被重复查到的那些行偏小。只有cmp的approximate那一列会告状。
zeros_like 不是 ones_like
dC = torch.zeros_like(C)初值是 0,两个理由:累加器的起点必须干净;而且这批样本里没出现过的字母, 梯度本来就该是 0。
print((dC.abs().sum(1) == 0).sum().item()) # 有几行全是 0这些行这一步不会被更新 —— 正常现象,第十三篇算过这笔账(用到哪行哪行才有梯度)。
⚠️ 别跟
ones_like混:zeros_like是累加器的初值(一个空容器),ones_like是局部导数表(每个位置的导数都是 1,用在形态 C 的广播里)。 两个函数长得像,用途完全不同 —— 上一篇第 ⑥ 步和第 ⑨ 步用的是后者。
跟第 19 篇第 ① 步的对照
两处都是「前向挑出来的,反向放回去」,但差一个关键点:
| 第 19 篇第 ① 步 | 本篇第 ⑤ 步 | |
|---|---|---|
| 前向 | logprobs[range(n), Yb] 每行取一个 | C[Xb] 取 96 次 |
| 反向 | 放回去,其余补 0 | 放回去,重复的要累加 |
| 会重复吗 | ❌ 每行只取一次 | ✅ 同一行可能取十几次 |
| 赋值 | = | += |
🔑 第十三篇那篇附录里记过「嵌入表梯度必须用
+=,求和号丢了就错」—— 那时是从雅可比里读出来的结论,这次是亲手写出来的实现。
Ex1 收官
| 参数 | 个数 | 段 |
|---|---|---|
W2 · b2 | 1755 | 4 |
bngain · bnbias | 128 | 5 |
W1 · b1 | 1984 | 6 |
C | 270 | 6 |
| 合计 | 4137 | ✅ |
从 loss 一路推到每一个参数,中间二十多个原子操作,一步不漏。
五种反向模式,加上这一篇的两条,全集就这些:
| 模式 | 最后出现在 |
|---|---|
| A 一对一 | 本篇第 ④ 步(极端情况:连乘法都不用) |
| B 一对多(求和) | 本篇第 ③ 步 db1 |
| C 多对一(广播) | 上一篇第 ⑥ ⑨ 步 |
| D 矩阵乘 | 本篇第 ①② 步 |
| E 索引 | 本篇第 ⑤ 步(唯一需要累加的版本) |
backward() 这一整段做的就是这些事,一件不多。下一段开始,把它换回一行 loss.backward() ——
但这次你知道那一行里面是什么。