bobofamilyNIU CHUN
18/36

数学补课 · 求和是复用的后果:sum(0) 还是 sum(1),比一次下标就知道

2026-09-1614 分钟—阅读

下一段要把一个两层网络的反向传播全部手推一遍 —— 二十多步,每一步自己写、自己和 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)·da  dy₂ = (∂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 多对多收缩输出独有的、留下变量独有的矩阵乘
四种形态不是四条规则。同一个 ∂yⱼ/∂xᵢ,非零项的分布不同而已 —— 而分布由两组下标怎么差决定。网格是固定一条样本之后的那一小块,行是输出的格子,列是变量的格子。

下面四节各走一种。第五节是重点,它回答标题那个问题。


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/xdprobs = (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)
缺的那一维,就是它在前向被复制的方向。竖着看一列,32 个格子用的是同一个 γ_j;横着看一行,27 个格子除的是同一个 S_b —— 梯度就沿那个方向汇总。

业务上的读法更本质:γ[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 ← 形态 B
dS = (−S⁻²)·dS⁻¹ = (−0.01)×(−3.3333) = +0.0333 ← 形态 A

然后逐格算两条路:

格子第一条路第二条路合计
e[0][2](那个 6.0)0.10 × (−0.556) = −0.05561 × 0.0333 = +0.0333−0.0223
e[0][0](那个 1.0)0.10 × 0 = 01 × 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,现在建立的直觉直接能用。

下一段开始手推那二十多步。工具齐了。