数学补课 · 照抄的导数是 1:梯度怎么落到嵌入表上
第九到十二篇把 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 = xW | W | ✅ 就是权重本身 |
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。
转置在做什么。 它不改变任何数值,只是让维度接得上:
| 形状 | |
|---|---|
∂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 = e | 1 |
| 其他所有情况 | 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 个格子:
每一行恰好有一个 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.01 | 0.01 / 0.01 = 1 |
emb[1][0] | 1.0 → 1.0,变了 0 | 0 / 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),取得规整好手算前向:
① 查表 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.755 | x₀, x₁ |
| 位置 1 | +0.090 | +0.665 | x₂, x₃ |
| 位置 2 | −0.755 | +0.090 | x₄, x₅ |
反展平和前向的 view 是同一个操作,只是方向相反。数值一个没变,只是重新排了形状。
落到 C,按 X = [2, 0, 2] 分组求和:
C 的行 | 被哪些位置用了 | 收到几份 | 梯度 |
|---|---|---|---|
C[0] | 位置 1 | 1 份 | [+0.090, +0.665] |
C[1] | 没有 | 0 份 | [0, 0] |
C[2] | 位置 0、位置 2 | 2 份,相加 | [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.4076print("∂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 = NoneF.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²。修完才有资格加深网络。