bobofamilyNIU CHUN
21/36

看着对的那一行:96 次查表落在 27 行上,和 Ex1 收官

2026-09-187 分钟—阅读

上一篇十三步拿下 1883 个参数,剩下 W1、b1、C 共 2254 个。

这一篇五步走完,没有分叉点 —— 从 hprebn 一路回到嵌入表 C,五步一条直线。 前三步是上一篇的复制粘贴,后两步各有一个前面没出现过的东西。

橙色是参数,灰色是数据 —— 五步里有三步产出参数梯度。没有一个分叉点,所以这是全 Ex1 里唯一一段不需要操心 += 顺序的(第 ⑤ 步的 += 是另一回事,见下文)。

前向三行:

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 / reshapeview 回去双射,一对一
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 · b217554
bngain · bnbias1285
W1 · b119846
C2706
合计4137✅

从 loss 一路推到每一个参数,中间二十多个原子操作,一步不漏。

五种反向模式,加上这一篇的两条,全集就这些:

模式最后出现在
A 一对一本篇第 ④ 步(极端情况:连乘法都不用)
B 一对多(求和)本篇第 ③ 步 db1
C 多对一(广播)上一篇第 ⑥ ⑨ 步
D 矩阵乘本篇第 ①② 步
E 索引本篇第 ⑤ 步(唯一需要累加的版本)

backward() 这一整段做的就是这些事,一件不多。下一段开始,把它换回一行 loss.backward() —— 但这次你知道那一行里面是什么。