bobofamilyNIU CHUN
13/36

数学补课 · 照抄的导数是 1:梯度怎么落到嵌入表上

2026-09-0713 分钟—阅读

第九到十二篇把 V3 那个 MLP 从头跑到尾,C 一直被当成「一张查表用的矩阵」。 但训练循环里它是和 W1、b1 并列写进 parameters 的 —— 它是参数,所以它得有梯度。

问题在于 C 夹在链路中间,而且它参与的那一步根本不是运算,是照抄:

emb = C[X]

矩阵乘法怎么求导有现成公式,「照抄」怎么求导没有。这一篇把它补上。


梯度的形状永远和它一样

深度学习里只会碰到一种情况:loss 是一个标量,对向量或矩阵求导。

形式导数的形状例子
标量 → 标量标量dL/dz
标量 → 向量 (n,)(n,)∂L/∂x,x 是输入
标量 → 矩阵 (m,n)(m,n)∂L/∂W,W 是权重

梯度的形状永远和被求导的对象一样。 这就是为什么参数更新能直接写成一行:

W -= lr * W.grad # 两边形状相同,逐元素相减

这种约定叫分母布局(denominator layout)。另一种叫分子布局,两者的转置位置正好相反。 看不同教材里同一个公式长得不一样,很多时候只是约定不同,不是谁写错了。 深度学习圈默认用分母布局,下面所有式子都是。


两条规则就够用

线性函数的导数就是它的系数

设

zⱼ = Σᵢ xᵢ · W[i][j]

把求和号展开:

zⱼ = x₀·W[0][j] + x₁·W[1][j] + … + xₖ·W[k][j] + …
↑
只有这一项含 xₖ

对 xₖ 求偏导时,其他项都是常数,导数为 0;剩下那一项是「常数 × 变量」,导数就是常数本身:

∂zⱼ/∂xₖ = W[k][j]

不需要背。W[k][j] 的含义本来就是「xₖ 对 zⱼ 的影响系数」,而导数问的正是这个。

线性和非线性的差别就在这里:

运算导数是常数吗
z = xWW✅ 就是权重本身
h = tanh(z)1 − h²❌ 依赖当前值

第二行是 tanh 饱和的根源:h 贴近 ±1 时 1 − h² → 0,梯度被掐灭。 上一篇结尾那句「梯度穿过 tanh 只剩一半」就是从这个式子来的 —— 不是 tanh 有什么特殊设计,是它的导数依赖当前值,而当前值跑到了饱和区。

那个求和号是「为什么相加」的唯一来源

如果 L 通过多个中间变量 z₁, z₂, … 依赖于 u:

∂L/∂u = Σⱼ (∂L/∂zⱼ) · (∂zⱼ/∂u)

这个求和号是后面一切「为什么要相加」的唯一来源。 u 通过几条路径影响 L, 就把几条路径的贡献加起来 —— 不需要任何额外解释。

第四篇里 _backward 必须写 += 而不是 =,说的就是这件事。 写成 = 等于把求和号弄丢了,后写的那一份会盖掉先写的。


线性层的反传公式

把上面两条规则写成矩阵形式,就得到两个在所有线性层都成立的式子:

z = xW ⟹ ∂L/∂x = ∂L/∂z · Wᵀ ∂L/∂W = xᵀ · ∂L/∂z

同一个东西,三个圈子三个叫法:

领域叫法
矩阵微积分矩阵形式的链式法则
深度学习线性层的反传公式
自动微分VJP(向量-雅可比积)

它不是线性代数定理,而是规则 1 + 规则 2 写成矩阵形式的结果。

VJP 这个名字值得知道:框架从不显式构造雅可比矩阵 J。对卷积层、注意力层, J 大到根本存不下。只算「J 乘一个向量」的结果,就绕开了构造矩阵本身。 PyTorch 里每个算子的 backward 实现的就是它自己的 VJP。

转置不改变任何数值,只是换个方向读同一张表:前向问「xₖ 流向哪些 z」,反向问「zⱼ 的梯度分给哪些 x」。

转置在做什么。 它不改变任何数值,只是让维度接得上:

形状
∂L/∂z(1, 3)
W(6, 3)
想要的 ∂L/∂x(1, 6)

(1,3) @ (6,3) 内维对不上;(1,3) @ (3,6) 才接得上 —— 所以 W 要转置。

数学上的含义是换个方向读同一张表:前向 z = xW 里,W 的第 k 行是 「xₖ 流向哪些 z」;反向 ∂L/∂x = ∂L/∂z·Wᵀ 里,第 j 列是 「zⱼ 的梯度分给哪些 x」。

两个式子转置的位置不同,也是同一个道理:

求什么转置谁
对输入求导转置 W —— 梯度往回流,穿过权重
对权重求导转置 x —— 梯度落在权重上,需要输入

不用背,靠形状凑。 手推反向传播那一集,Karpathy 全程用的就是这个办法 —— 看哪种写法能接上就怎么写,然后跑一次数值梯度检查验证。


C 的那一步:索引怎么求导

前面都是标准矩阵乘。C[X] 特殊在于它不是运算,是照抄。

两种等价写法

写法 A(代码里的):

emb[n, t, d] = C[ X[n, t], d ]

三个下标定位一个标量:n 是第几个样本,t 是上下文里第几个字符(0/1/2), d 是这个字符向量的第几个分量。

emb 是 (N, 3, 2) 三维,不是 3×2。emb[3] 才是 3×2。 每加一个下标少一个维度,三个下标用完得到一个数。

写法 B(求导用这个):

emb = onehot(X) · C

因为 one-hot 里只有一个 1,矩阵乘退化成了「取第几行」。

这就是一次标准的 x @ W —— C 就是第一层的权重矩阵。 不写成 one-hot 相乘,只是因为那样 99% 的计算都在乘 0。所以真实结构是三层:

one-hot(27) ──@C──▶ (n_embd) ──@W1──▶ (n_hidden) ──@W2──▶ (27)

雅可比是一张 0/1 选择矩阵

前向就是照抄,而 y = x 的导数恒为 1。所以:

条件∂emb[n,t,d] / ∂C[v,e]
X[n,t] = v 且 d = e1
其他所有情况0

两个条件缺一不可:行要对上(C 的第 v 行必须正好是这个位置用到的字符)、 列要对上(d = e,第 0 个分量只影响第 0 个分量)。

「其他情况」容易含糊过去,所以把它拆开。取 X = [2, 0, 2]、n_embd = 2, 固定问一个格子:C[2][0] 变化时,emb 的各个格子怎么变?

目标格子它等于对 C[2][0] 的导数为什么
emb[0][0]C[2][0]1就是它本人
emb[0][1]C[2][1]0行对了(都是 2),列不对
emb[1][0]C[0][0]0列对了(都是 0),行不对
emb[1][1]C[0][1]0行、列都不对
emb[2][0]C[2][0]1又是它本人
emb[2][1]C[2][1]0行对了,列不对

「其他情况」于是有三种:行不对列对、行对列不对、行列都不对。 两个条件各自在说一件事:X[n,t] = v 问的是这个位置用没用到 C 的第 v 行; d = e 问的是分量 —— C[v][0] 和 C[v][1] 是两个独立的数,改一个不影响另一个。

把整张雅可比写出来,行是 emb 的 6 个格子,列是 C 的 6 个格子:

36 个位置只有 6 个 1。按行读是「这一格从哪儿抄来的」,按列读就是反向的分拣规则 —— 稀疏和累加两个性质,都在这一张表上。

每一行恰好有一个 1,标记它是从哪里抄来的。按列读则是反向的分拣规则:

列这一列有几个 1含义
C[0][0]、C[0][1]各 1 个字符 0 被用了 1 次 → 收 1 份梯度
C[1][0]、C[1][1]0 个字符 1 没出现 → 梯度必然为 0
C[2][0]、C[2][1]各 2 个字符 2 被用了 2 次 → 收 2 份,相加

和普通线性层比,差别只在雅可比的样子:

前向雅可比
z = xW加权求和W 本身,稠密实数
emb = C[X]照抄只含 0/1,极稀疏

「照抄」的导数是 1,「不相关」的导数是 0 —— 没有中间值。

0/1 说的是导数,不是数值

这里最容易搞混:emb 里当然全是实数,那正是 C 里存的值。 0/1 描述的是另一个对象 —— C 变一点,emb 跟着变多少的比例。

是什么长什么样
emb 的数值从 C 搬过来的实数0.31、−0.82、1.05… 任意实数
∂emb/∂C 的导数变化的传递系数只有 0 和 1

用数字看一遍。设 C[2] = [2.0, −1.0],X = [2, 0, 2],于是 emb[0] = emb[2] = [2.0, −1.0]。把 C[2][0] 从 2.0 改成 2.01:

格子变化导数 = 变化量之比
emb[0][0]2.0 → 2.01,变了 0.010.01 / 0.01 = 1
emb[1][0]1.0 → 1.0,变了 00 / 0.01 = 0

看第二行:导数是 0,但 emb[1][0] 的值是 1.0,不是 0。 两回事。

导数只能是 0 或 1,是因为前向是恒等复制 emb[0][0] = C[2][0], 这就是 y = x,导数恒为 1 —— 不管 x 是 2.0 还是 −0.82 还是 1000。 对比一下:如果前向写成 emb[0][0] = 3.5 * C[2][0],导数就是 3.5。 索引没乘任何系数,或者说系数恒等于 1。

那 C.grad 为什么不是 0/1

因为链式法则里还乘了上游梯度。以下面那个手算例子的数为例:

∂L/∂C[2][0] = 0.665 × 1 + (−0.755) × 1 = −0.090
‾‾‾‾‾ ‾ ‾‾‾‾‾‾ ‾
上游 选择 上游 选择

0/1 是「开关」,不是「值」。 它只决定哪些上游梯度能通过、哪些被挡住; 通过的那些梯度本身还是实数。所以 C.grad 里全是 −0.090、0.665 这类数, 只是有些行整行为 0(开关全关)、有些行是多份相加(开关开了多次)。

把 0/1 代进链式法则,大部分项归零,d 那层求和也消失(因为必须 d = e):

∂L/∂C[v,e] = Σₙ,ₜ 1[ X[n,t] = v ] · ∂L/∂emb[n,t,e]

白话就是:C 第 v 行的梯度 = 把 batch 里所有用到字符 v 的位置, 它们的 emb 梯度全部加起来。

两个性质:稀疏与累加

稀疏 —— 没用到的行梯度为 0。一个 batch 只碰得到有限几个字符, C 里没被碰到的行,梯度整行是 0,那一步更新它一动不动。

上一篇散点图上 q 飞到最顶端,原因就在这里:它在名字里太罕见, 大多数 batch 根本用不到 C 的第 17 行,所以那一行几乎没被更新过,还停在随机初始化附近。

累加 —— 同一个字符在一个 batch 里出现多次,梯度要相加。 这完全来自规则 2 里的求和号,不需要其他解释。

PyTorch 不会真的造一个 one-hot 矩阵去乘,用的是 index_add_:

# 概念上等价于
C.grad = torch.zeros_like(C)
for n in range(N):
for t in range(3):
v = X[n, t]
C.grad[v] += emb.grad[n, t] # ← 累加,不是赋值

nn.Embedding 内部就是这个,而且支持 sparse=True —— 词表几万时能省大量内存。


缩到能手算

把一切缩到纸上算得完:词表 3 个字符、n_embd = 2、一个样本、去掉隐藏层。

C = [[1.0, 0.0], # 字符 0
[0.0, 1.0], # 字符 1
[2.0, -1.0]] # 字符 2
X = [2, 0, 2] # 上下文
Y = 1 # 正确答案
W = [[1,0,0],[0,1,0],[0,0,1],
[1,0,0],[0,1,0],[0,0,1]] # (6,3),取得规整好手算
上下两排同一列对齐。反向那一排最左端就是落点:C[0] 收 1 份、C[1] 收 0 份、C[2] 收 2 份相加。

前向:

① 查表 emb[0]=C[2]=[2,−1] emb[1]=C[0]=[1,0] emb[2]=C[2]=[2,−1]
② 展平 x = [2, −1, 1, 0, 2, −1]
③ logits logit₀ = x₀+x₃ = 2 logit₁ = x₁+x₄ = 1 logit₂ = x₂+x₅ = 0
④ softmax probs = [0.665, 0.245, 0.090]
⑤ loss L = −ln(0.245) = 1.4076

反向,起点是 ∂L/∂logits = probs − onehot(Y),Y = 1:

∂L/∂logits = [0.665−0, 0.245−1, 0.090−0] = [+0.665, −0.755, +0.090]

这三个数加起来正好等于 0 —— 概率和为 1,onehot 和也为 1,一个现成的自检。

穿过 W:∂L/∂xₖ = Σⱼ (∂L/∂logitⱼ) · W[k][j]。W 每一行只有一个 1, 所以每个 xₖ 原样领走它指向的那个 logit 的梯度:

∂L/∂x = [+0.665, −0.755, +0.090, +0.665, −0.755, +0.090]

反展平,6 个数按 (3,2) 折回去:

位置第 0 维第 1 维来自
位置 0+0.665−0.755x₀, x₁
位置 1+0.090+0.665x₂, x₃
位置 2−0.755+0.090x₄, x₅

反展平和前向的 view 是同一个操作,只是方向相反。数值一个没变,只是重新排了形状。

落到 C,按 X = [2, 0, 2] 分组求和:

C 的行被哪些位置用了收到几份梯度
C[0]位置 11 份[+0.090, +0.665]
C[1]没有0 份[0, 0]
C[2]位置 0、位置 22 份,相加[0.665−0.755, −0.755+0.090] = [−0.090, −0.665]

为什么相加

设 u = C[2][0]。前向里它被复制到两处:emb[0][0] = u、emb[2][0] = u。套规则 2:

∂L/∂u = (∂L/∂emb[0][0])·1 + (∂L/∂emb[2][0])·1
= 0.665 + (−0.755)
= −0.090

两个 ∂emb/∂u 都等于 1,因为都是恒等复制。「为什么相加」的全部理由就是那个求和号。

两项符号相反、加起来部分抵消,这只是求和的算术结果,没有更多含义。

不要把梯度拟人化。 「位置 0 希望它变小、位置 2 希望它变大」这种说法没有对应的数学对象, 而且容易把方向搞反 —— 梯度为正时,p -= lr * grad 是让参数减小。 梯度的唯一含义:u 增加 ε,L 大约变化 (∂L/∂u)·ε。就这一句。


两段验证代码

第一段把上面手算的每一步跑一遍:

import torch, torch.nn.functional as F
C = torch.tensor([[1.,0.],[0.,1.],[2.,-1.]], requires_grad=True)
W = torch.eye(3).repeat(2,1) # (6,3)
X = torch.tensor([[2,0,2]])
Y = torch.tensor([1])
emb = C[X]
emb.retain_grad() # ← 中间张量默认不存梯度,加这行才能打印
loss = F.cross_entropy(emb.view(1,-1) @ W, Y)
loss.backward()
print("loss =", loss.item()) # 1.4076
print("∂L/∂emb =\n", emb.grad[0])
print("∂L/∂C =\n", C.grad)
# 验证 C[2] 是两份相加
print("验证:", (emb.grad[0,0] + emb.grad[0,2]).tolist(),
"应等于", C.grad[2].tolist())

最后一行会打印出两个相同的列表 —— 就是「位置 0 + 位置 2 = C[2]」的直接证明。

第二段在真实模型上验稀疏性:

ix = torch.randint(0, Xtr.shape[0], (32,))
for p in parameters: p.grad = None
F.cross_entropy(forward(Xtr[ix]), Ytr[ix]).backward()
nz = (C.grad.abs().sum(1) > 0)
print("有梯度的行:", ''.join(itos[i] for i in range(27) if nz[i]))
print("batch 用到的:", ''.join(itos[i] for i in sorted(set(Xtr[ix].flatten().tolist()))))

两行输出完全一致 —— 直接验证了那个指示函数 1[X[n,t] = v]。


这一篇没有引入任何新机制,只是把第九篇那句「C 也是参数」算给自己看: C 就是第一层的权重,只不过它的雅可比退化成了一张 0/1 选择矩阵, 于是「对权重求导」退化成了「按行分组求和」。

回到主线。下一集要修三件事,全部已经实测过:初始 loss 该是 3.30 却是 17; h 里贴着 ±1 的比例远超应有;梯度穿过 tanh 只剩一半 —— 最后这一条的式子, 就是上面规则 1 里的 1 − h²。修完才有资格加深网络。