数学补课 · 求和是复用的后果:sum(0) 还是 sum(1),比一次下标就知道
下一段要把一个两层网络的反向传播全部手推一遍 —— 二十多步,每一步自己写、自己和
loss.backward() 对答案。开始之前得先确认手里的工具够用。
目前手里有两样:第三篇给过多元链式法则那个求和号,第十三篇用它推出了线性层的反传公式。 但那是一个特例。 真正会卡住的是另一类问题:
dbngain = (bnraw * dhpreact).sum(0, keepdim=True)dcounts_sum_inv = (counts * dprobs).sum(1, keepdim=True)为什么这里要 sum?为什么一个是 0 一个是 1?为什么还有些地方反过来要广播?
这三个问题第三篇和第十三篇都没回答 —— 这一篇回答。
第十三篇那几节这里只用不讲:梯度的形状永远和被求导的对象一样(分母布局)、 线性层反传公式本身、VJP 是什么。下面直接拿来用。
加法是从哪来的
多元链式法则,第三篇和第十三篇都写过:
∂L/∂xᵢ = Σⱼ (∂L/∂yⱼ) · (∂yⱼ/∂xᵢ)
两篇都是当作事实给出来的:「x 通过几条路径影响 L,就把几条路径的贡献加起来」。
这一篇先把那个加号推一遍 —— 因为后面四种形态全是它变出来的,而且求和号活不活得下来
正是 sum 和广播的唯一判据。
最小的例子
y₁ = 2a,y₂ = 3a,L = y₁² + y₂
直接代入:L = 4a² + 3a,所以 dL/da = 8a + 3。
分路再相加:
经 y₁:(∂L/∂y₁)·(∂y₁/∂a) = 2y₁ × 2 = 4a × 2 = 8a经 y₂:(∂L/∂y₂)·(∂y₂/∂a) = 1 × 3 = 3 相加 = 8a + 3两条路算出来一样。a = 1.7 时数值导数是 16.600000,8×1.7+3 = 16.6 ✅
为什么是加法,不是别的
a 变化 da 时,每个 yⱼ 都会跟着变:
dy₁ = (∂y₁/∂a)·dady₂ = (∂y₂/∂a)·da
而 L 的变化,是所有 yⱼ 的变化共同造成的:
dL = (∂L/∂y₁)·dy₁ + (∂L/∂y₂)·dy₂
把上面两式代进去,da 的系数就是 dL/da,也就是链式法则那个式子。
加法来自「L 的总变化 = 各条路径造成的变化之和」。这是全微分的定义,不是谁定的约定。
后面四种形态里唯一在变的东西,就是 ∂yⱼ/∂xᵢ 这张表里有多少项是 0。
比一次下标,四种形态就分完了
∂yⱼ/∂xᵢ 这张表有名字,第十三篇画过它 —— 雅可比。那一篇画的是 C[X] 的雅可比,
36 个格子里只有 6 个 1。这一篇要说的是:那张表的其它长相,一共也就四种。
而且不用算就能知道是哪一种。判据只有一条:比较变量的下标集和输出的下标集。
| 变量下标 vs 输出下标 | 形态 | 反向怎么做 | 形状 |
|---|---|---|---|
| 完全一致 | A 一对一 | 乘局部导数 | 不变 |
| 变量少一个 | B 一对多 | sum 掉那一维 | 塌一维 |
| 输出少一个 | C 多对一 | 沿那一维广播 | 展一维 |
| 各少一个 | D 多对多 | 收缩输出独有的、留下变量独有的 | 矩阵乘 |
下面四节各走一种。第五节是重点,它回答标题那个问题。
A 一对一:雅可比是对角阵
特征是等号两边下标完全一样:
y[b,c] = f( x[b,c] )
x[b,c] 只出现在 y[b,c] 的定义里,别的格子跟它无关。代进通用公式,
求和号里那 864 项有 863 项是 0,只剩一项:
∂L/∂x[b,c] = (∂L/∂y[b,c]) · f′(x[b,c])
dx = f_prime(x) * dy # 形状不变雅可比长什么样:只有对角线上非零。所谓「逐元素」,说的就是这张对角阵 —— 对角阵乘一个向量,本来就退化成逐元素相乘。
第三篇讲单输出网络时说过一句:「单输出网络不是「没有求和」,而是求和只有一项。」 形态 A 是同一件事在张量上的版本 —— 求和号还在,只是 863 项被 0 吃掉了。
实例
| 前向 | 局部导数 | 代码 |
|---|---|---|
logprobs = probs.log() | 1/x | dprobs = (1/probs) * dlogprobs |
counts = norm_logits.exp() | exp(x),就是 counts 本身 | dnorm_logits = counts * dcounts |
counts_sum_inv = counts_sum ** -1 | −x⁻² | dcounts_sum = (−counts_sum**-2) * dcounts_sum_inv |
h = torch.tanh(hpreact) | 1 − h²,用输出不是输入 | dhpreact = (1 − h**2) * dh |
后两行都是拿前向已经算好的输出当局部导数,不重算。这是反向传播省算力的常见手法,
也是第十三篇那句「tanh 的导数依赖当前值」的另一面 —— 依赖当前值,所以当前值可以直接拿来用。
取倒数那一行容易写废
counts_sum_inv = counts_sum ** -1 求导时,别写成 S⁻¹ = S⁻¹(那是句废话)。
左边是另一个变量:
T = S⁻¹⟹dT/dS = −S⁻²
幂函数求导 d(xⁿ)/dx = n·xⁿ⁻¹,代 n = −1 得 −x⁻²。
数值验证:S = 10.000 时 T = 0.100000,S = 10.001 时 T = 0.099990,
放大倍数 −0.00001/0.001 = −0.01,而 −1/10² = −0.01 ✅
负号的含义很直白:分母变大,倒数变小,方向相反。
B 一对多:sum 掉那一维
这一节是全篇的重点。特征是变量的下标比输出少一个:
y[b,c] = x[b,c] · a[b]
盯住 a[b]。固定一个 b,把 c 展开:
y[b,0] = x[b,0]·a[b]y[b,1] = x[b,1]·a[b] … ← 27 个式子,每个都含 a[b]y[b,26] = x[b,26]·a[b]这正是第二节那个「一个 a 对多个 y」的结构,只不过路数从 2 条变成 27 条。
局部导数是一次函数的系数:
∂y[b,c]/∂a[b] = x[b,c]
所以:
∂L/∂a[b] = Σ_c (∂L/∂y[b,c]) · x[b,c]
da = (x * dy).sum(1, keepdim=True) # (32,27) → (32,1)求和号为什么只遍历 c,不遍历 b
严格写应该是双重求和,遍历 y 的全部分量:
∂L/∂a[b] = Σ_b′ Σ_c (∂L/∂y[b′,c]) · (∂y[b′,c]/∂a[b])但 b′ ≠ b 时,y[b′,c] = x[b′,c]·a[b′] 里根本没有 a[b],那一项的偏导是 0。
32 × 27 = 864 项里只有 27 项非零,双重求和塌成单重。
求和号哪一个活下来,取决于哪些偏导非零。 b 那一维全塌了,c 那一维全活着。
🔑 「缺下标」不是求导规则,是一个语法标记
这里容易把两件事混成一句:
| 问题 | 由什么决定 |
|---|---|
| 对谁求导? | 你的目的。想更新 γ[j] 就对 γ[j] 求导 —— 跟下标无关 |
| 求和号沿哪一维展开? | 这才是下标决定的 |
准确的说法是比较两个下标集:
| 情况 | 结果 |
|---|---|
| 下标两边都有 | 对上号,求和塌成一项 |
| 只在输出里有,变量里没有 | 求和号活着 → sum 这一维 |
| 只在变量里有,输出里没有 | 广播(下一节) |
「缺下标」本身不是求导规则,它是「这个量被复用了多少次」的语法标记。 被复用 → 影响多个下游 → 链式法则要把多条路加起来。求和是复用的必然后果。
轴怎么定
| 变量 | 下标 | 相对输出缺谁 | 求和轴 |
|---|---|---|---|
γ[j](BatchNorm 的增益) | 只有 j | 缺 b(样本) | sum(0) |
S[b](softmax 的分母) | 只有 b | 缺 c(类别) | sum(1) |
业务上的读法更本质:γ[j] 被这一批 32 条样本共用,32 条各有各的意见,
sum(0) 就是把这 32 条汇总成一个数。 下标只是把「被谁共用了」写在了纸上。
一般形式
求和号的位置永远一样,变的只有局部导数:
| 前向 | 局部导数 | 结果 |
|---|---|---|
y[b,c] = x[b,c]·a[b] | x[b,c] | Σ_c x·dy |
y[b,c] = x[b,c] − a[b] | −1 | −Σ_c dy |
y[b,c] = x[b,c] + a[c] | 1 | Σ_b dy(这就是 db2) |
keepdim=True不能省。第八篇讲过它是什么(求和后那一维留不留着,长度变成 1); 在反向里它是硬要求 —— 少了它(1,64)会塌成(64,), 梯度的形状就对不上被求导的那个对象了。⚠️ 更麻烦的是写错轴不报错。第八篇把这个当「本节最大的坑」讲过: 形状常常恰好能广播成功,PyTorch 认为你就是想这么算。
C 多对一:广播回去
B 的镜像。特征是输出的下标比变量少一个 —— 也就是前向那一行带了求和号:
y[b] = Σₖ x[b,k]
盯住 x[b,c]:它只贡献给 y[b] 这一个数。
为什么系数全是 1
把式子按「含不含 x[b,2]」分成两堆:
y[b] = x[b,2] + ( x[b,0] + x[b,1] + x[b,3] + … ) ↑含它 ↑不含它,对 x[b,2] 而言全是常数,记作 C这就是最简单的一次函数 y = x + C,所以 ∂y[b]/∂x[b,2] = 1 + 0 = 1。
数值验证:[1.0, 2.0, 6.0, 1.0] 的总和是 10.000,把 6.0 加 0.001 → 总和 10.001。
输入涨 0.001,输出涨 0.001,倍数 1 ✅
换成 x[b,0] 求导,同样的拆法 —— 27 项拆出来都是 y = x + C 的形式,所以 27 个偏导全是 1。
本质是:求和是线性的,而且每一项的权重都是 1,没有系数。
带系数的情形也就跟着有了:
μ[j] = (1/n)·Σ_b x[b,j]⟹∂μ[j]/∂x[b,j] = 1/n
dhprebn += (1.0/n) * torch.ones_like(hprebn) * dbnmeani # ← 不再是 1结果是广播
∂L/∂x[b,c] = 1 · (∂L/∂y[b])
右边没有下标 c —— 这一行里 27 个位置拿到的梯度完全一样,所以是广播:
dx = torch.ones_like(x) * dy # (32,1) → (32,27)# 系数全是 1,其实可以直接写 dx = dy,广播会自动展开# 写全 ones_like 只是为了让局部导数显式出现B 和 C 严格互为逆运算
| 前向 | 形状 | 反向 | 形状 |
|---|---|---|---|
| 广播(1 份变 27 份) | (32,1) → (32,27) | 求和 | (32,27) → (32,1) |
| 求和(27 份变 1 份) | (32,27) → (32,1) | 广播 | (32,1) → (32,27) |
记住一个,另一个自动就有。
注意同一个下标 c 的「缺失」,在两个位置上产生的是完全相反的操作:
缺在变量上要 sum,缺在输出上要广播。
D 多对多:第十三篇那个公式,这次推出来
第十三篇已经给过结论:
z = xW ⟹ ∂L/∂x = ∂L/∂z · Wᵀ ∂L/∂W = xᵀ · ∂L/∂z而且给了一个很实用的办法 —— 靠形状凑:dy 是 (32,27)、W 是 (64,27),
要凑出 (32,64),四种组合里只有一种内维对得上。
dy @ W.T (32,27) @ (27,64) → (32,64) ✅ 和 x 同形W @ dy (64,27) @ (32,27) 内维 27 ≠ 32 ✗dy.T @ W (27,32) @ (64,27) 内维 32 ≠ 64 ✗W.T @ dy (27,64) @ (32,27) 内维 64 ≠ 32 ✗这一节补的是为什么 —— 以及凑形状给不出来的那件事。
前向是 y[b,j] = Σᵢ x[b,i]·W[i,j]。两组下标:变量 x 是 [b,i],输出 y 是 [b,j]。
各自都有对方没有的那一维 —— 这就是形态 D。判据说:收缩输出独有的,留下变量独有的。
对 x 求导,收缩 j:
∂L/∂x[b,i] = Σⱼ (∂L/∂y[b,j]) · W[i,j]
对 W 求导,W[i,j] 出现在全部 32 条样本的第 j 个输出里,收缩 b:
∂L/∂W[i,j] = Σ_b (∂L/∂y[b,j]) · x[b,i]
dx = dy @ W.T # (32,27) @ (27,64) → (32,64)dW = x.T @ dy # (64,32) @ (32,27) → (64,27)转置在做什么,现在也清楚了:把要求和的那个下标挪到矩阵乘法要求的位置上。
Σⱼ W[i,j]·dy[b,j] 是「W 的第 i 行」点乘「dy 的第 b 行」,
而矩阵乘法要求行乘列 —— 转置一下,第 i 行就变成第 i 列。转置不是技巧,是对齐下标。
🔑 凑形状和推下标的差别,就在这里。 凑形状告诉你该怎么写;推下标告诉你这个梯度在汇总谁的意见 ——
∂L/∂x遍历j,汇总的是 27 个输出的意见;∂L/∂W遍历b, 汇总的是 32 条样本的意见,和上一节γ[j]是同一回事。这也解释了为什么
dW里被转置的是x而不是dy:要贴在一起做内积的是样本那一维。
同一个 Σ 的第二种读法
还剩一件事,而且它最容易漏:一个变量出现在多个式子里。
counts_sum = counts.sum(1, keepdims=True) # counts 用了一次probs = counts * counts_sum_inv # 又用了一次第十三篇已经把这件事的道理写死了:「这个求和号是后面一切『为什么要相加』的唯一来源。」 所以下面这条不是第五种形态,也不是新规则 —— 它是同一个 Σ 的第二种读法:
| Σ 跨的是什么 | 得到 |
|---|---|
| 一个下游张量的各个分量 | 上面那四种形态 |
| 多个下游张量 | 每条路各算一遍,然后相加(+=) |
两条路
写成公式看得最清楚:
p[b,c] = e[b,c] · S[b]⁻¹S[b] = Σₖ e[b,k]
e[b,2] 在两个等号右边都出现了。它动一点点,两条路同时起效:
e[b,2] 动 ε ├─→ p[b,2] 直接跟着动 (短路径,形态 A) └─→ S[b] 动 → S[b]⁻¹ 动 → 整行 27 个 p 全动 (长路径,形态 C)
∂L/∂e[b,c] = S[b]⁻¹·(∂L/∂p[b,c]) + 1·(∂L/∂S[b])
dcounts = counts_sum_inv * dprobs # 第一条dcounts += torch.ones_like(counts) * dcounts_sum # 第二条,必须 +=❗ 为什么漏掉第二条很难查出来
拿样本 0 的四个数走一遍:e₀ = [1.0, 2.0, 6.0, 1.0],S₀ = 10.0,S₀⁻¹ = 0.10,
上游 dp₀ = [0, 0, −0.556, 0]。先算出 dS₀:
dS⁻¹ = Σ_c e·dp = 6.0 × (−0.556) = −3.3333 ← 形态 BdS = (−S⁻²)·dS⁻¹ = (−0.01)×(−3.3333) = +0.0333 ← 形态 A然后逐格算两条路:
| 格子 | 第一条路 | 第二条路 | 合计 |
|---|---|---|---|
e[0][2](那个 6.0) | 0.10 × (−0.556) = −0.0556 | 1 × 0.0333 = +0.0333 | −0.0223 |
e[0][0](那个 1.0) | 0.10 × 0 = 0 | 1 × 0.0333 = +0.0333 | +0.0333 |
看第二行:第一条路是 0,第二条路不是。
只写 = 不写 +=,这些格子的梯度会直接变成 0 ——
而第一条路单独看完全正确,公式没错、代码没报错、结果是个合法的张量。
第十三篇那句「写成 = 等于把求和号弄丢了」,丢的就是这种。
顺带解释了一件事:为什么 dcounts 之后梯度就不稀疏了。
第二条路给每一个格子都加了一个非零值,原先那 832 个 0 被填满了。
怎么把分叉点找全
把变量名在整组前向公式里搜一遍,它出现在几个等号右边,就有几条路。
| 变量 | 出现在 | 路数 |
|---|---|---|
u[b,c] | 只在 e[b,c] = exp(u[b,c]) | 1 |
S[b] | 只在 S[b]⁻¹ 那一行 | 1 |
e[b,c] | p[b,c] = e·S⁻¹、S[b] = Σₖ e[b,k] | 2 |
z[b,c] | m[b] = maxₖ z[b,k]、u[b,c] = z[b,c] − m[b] | 2 |
推导之前先把前向扫一遍,把分叉点圈出来,写代码时就不会漏 +=。
判别流程
拿到一行前向公式,盯住要求导的那个变量 ↓① 它在整组公式里出现几次? > 1 次 → 每条路各算一遍,最后相加(+=) ↓② 对每一条路,比较变量的下标集和输出的下标集 ↓ 完全一致 → A:乘局部导数,形状不变 变量少一个 → B:sum 掉那一维,形状塌 输出少一个 → C:沿那一维广播,形状展 各少一个 → D:收缩输出独有的、留下变量独有的,写成矩阵乘 ↓③ 查一遍形状:dX.shape 必须等于 X.shape第三步是第十三篇那条铁律,也是最便宜的一道检查 —— 形状不对,前面两步必然有一步错了。
这一篇没有引入任何新机制,只是把第三篇那个求和号推到底:加法来自全微分, 四种形态来自「哪些偏导是 0」,而那又来自两组下标怎么差。
一句话版本:求和不是一条要记的规则,是复用的后果。
一个量在前向被复制了多少次,反向就有多少份梯度要加回去;
它沿哪一维被复制,就沿哪一维 sum。
🔮 「看下标决定求和」这套记号有个正式名字:爱因斯坦求和约定。
torch.einsum用的就是它,规则是输出下标里没有的那些下标,全部求和 —— 和这一篇推出来的完全同构。后面做多模态会大量碰到einsum,现在建立的直觉直接能用。
下一段开始手推那二十多步。工具齐了。