案例拆解:每个箭头对应哪行公式
上一篇把四张图看成了四种连接方式。这一篇换个看法:把图放大到一条箭头,问它到底在算什么。
一张图里最容易被忽略的是箭头方向。前向时,箭头是数据流:输入从左边进去,一层层变形状,最后变成输出。反向时,箭头倒着走,传的是梯度:损失对每个参数的偏导,从右边一路退回来。
这一篇只拆前向。反向留到第 2 章,到时候你会发现,每条前向箭头都对应一条反向箭头,公式还是那几行,只是乘的顺序反了。
<!-- 配图:三个输入、三个权重、一个求和节点、一个 ReLU、一个输出 -->
一、一个神经元:一次点积,一次激活
先看最小的单位。图里有三个输入 x₁、x₂、x₃,三条箭头标着 w₁、w₂、w₃,中间一个 Σ,右边一个 ReLU。
别把它当成抽象符号,代几个数进去。
x = [1.0, 2.0, -1.0]
w = [0.2, -0.5, 0.1]
b = 0.3
z = 0.2×1.0 + (-0.5)×2.0 + 0.1×(-1.0) + 0.3
= 0.2 - 1.0 - 0.1 + 0.3
= -0.6
a = ReLU(-0.6) = 0z 是 -0.6,ReLU 把它压成 0。这个神经元这一轮没有输出。
如果把偏置 b 从 0.3 改成 1.0,z 就变成 0.1,a 就是 0.1。输入和权重一个没动,只挪了偏置,神经元从「死」变成「活」。
这就是偏置的作用:它不是可有可无的截距,它把激活函数的阈值挪了。没有 b,ReLU 的拐点永远在 0;有了 b,拐点可以移到任意位置。
写成代码只有两行:
z = sum(w[i] * x[i] for i in range(3)) + b
a = max(0, z)如果 x 和 w 是 NumPy 数组,第一行就是 z = w @ x + b。@ 是点积,不是矩阵乘的语法糖——三个乘加一次算完。
前向到这里就结束了。但图上那条 w₁ 的箭头,在反向时还要再走一遍:
∂L/∂x₁ = ∂L/∂z × w₁
∂L/∂w₁ = ∂L/∂z × x₁
∂L/∂b = ∂L/∂z前向时 x₁ 乘 w₁ 进 z;反向时 ∂L/∂z 分别乘 w₁ 和 x₁,退回给 x₁ 和 w₁。同一个权重,前向当乘数,反向还是当乘数,只是乘的对象换了。
这里埋着第一个坑:如果 z < 0,ReLU 的导数是 0,∂L/∂z 就是 0。于是 ∂L/∂w₁、∂L/∂x₁、∂L/∂b 全是 0,这个神经元这一轮学不到任何东西。学习率太大、初始化太偏,都会让一批神经元长期落在负半轴,怎么训都不动。第 3 章会专门处理这个问题。
二、一层:一次矩阵乘
<!-- 配图:W(4,3) @ x(3,2) = z(4,2),再加偏置 b -->
一个神经元是一次点积。一层有多个神经元,如果一个个算,代码里就是一串 for 循环。实际实现不会这么写,因为一层里所有神经元的点积可以合成一次矩阵乘。
假设这一层有 4 个神经元,输入 3 维,一次喂 2 条样本:
W: (4, 3) | 4 个神经元,每个 3 个权重
x: (3, 2) | 3 个特征,2 条样本
b: (4, 1) | 4 个偏置
z = W @ x + b | (4, 2)形状对上了:(4,3) @ (3,2) → (4,2),b 是 (4,1),按列广播到两条样本。
代码(零依赖,和实战篇保持同一套环境):
x = [[1.0, 2.0],
[3.0, 4.0],
[5.0, 6.0]] # (3, 2)
W = [[0.0, 0.1, 0.2],
[0.3, 0.4, 0.5],
[0.6, 0.7, 0.8],
[0.9, 1.0, 1.1]] # (4, 3)
b = [[0.1], [0.2], [0.3], [0.4]] # (4, 1)
z = [[sum(W[r][c] * x[c][k] for c in range(3)) + b[r][0]
for k in range(2)] for r in range(4)]
print(len(z), len(z[0])) # 4 2同一个结果换成 NumPy 只要一行 z = W @ x + b,但形状规则一模一样:(4,3) @ (3,2) → (4,2)。先用手写循环把下标走一遍,换到框架里才知道每个维度在干什么。
参数量是 4×3 + 4 = 16。输出维度由 W 的行数决定,跟输入维度无关;输入维度由 W 的列数决定。
第一个坑是乘法顺序。W @ x 和 x @ W 不是一回事:
W(4,3) @ x(3,2) → (4,2) 正确
x(3,2) @ W(4,3) → 形状不匹配,直接报错第二个坑是 batch 维放哪。NumPy 里习惯 (batch, feature),PyTorch 的 nn.Linear 也吃 (batch, in_features);但手写矩阵乘时,把特征放前面、batch 放后面更好写。两种写法都能跑,混着用就会在某个 reshape 处炸掉。选定一种,全篇统一。
第三个坑是偏置的形状。b 是 (4,1) 而不是 (4,),因为后者在广播时会被当成行向量,跟 (4,2) 加不上。写成 (4,) 时,NumPy 会按最后一维广播,结果要么报错,要么悄悄算错。
三、CNN 的箭头:滑动窗口的点积
<!-- 配图:5×5 输入取左上 3×3,与 3×3 核逐元素相乘,求和得到 411 -->
卷积核不是玄学,它就是一个 3×3 的小矩阵。把它盖在输入的一个 3×3 窗口上,逐元素相乘,再全部加起来,得到一个输出值。
图里的输入是 1 到 25 的 5×5 矩阵,核是 1 到 9 的 3×3 矩阵。取左上角窗口:
窗口: 核: 逐元素乘:
1 2 3 1 2 3 1×1 2×2 3×3
6 7 8 4 5 6 6×4 7×5 8×6
11 12 13 7 8 9 11×7 12×8 13×9
s = 1 + 4 + 9 + 24 + 35 + 48 + 77 + 96 + 117 = 4119 次乘法,8 次加法,再加一个偏置,就是一个输出值。窗口往右挪一格,再来一次;挪完一行,往下走一行。5×5 的输入、3×3 的核、步长 1,输出是 3×3。
写成代码:
def conv2d(x, k, b):
H, W = x.shape
KH, KW = k.shape
out = np.zeros((H - KH + 1, W - KW + 1))
for i in range(out.shape[0]):
for j in range(out.shape[1]):
out[i, j] = np.sum(x[i:i+KH, j:j+KW] * k) + b
return out这段是四重循环的朴素版,跑得慢,但每个下标在干什么一清二楚。PyTorch 的 F.conv2d 内部用 im2col 把它变成一次矩阵乘,结果一样,速度快几十倍。第 4 章会让你手写一遍,再和 F.conv2d 逐元素对齐。
参数共享就发生在这段代码里:k 在 9 个位置上被反复用,但只有一份。假设每个位置各有一套权重,5×5 输入需要 9 个位置 × 9 个权重 = 81 个,再加 9 个偏置;共享之后只要 9 + 1 = 10 个。
多通道时,参数量是:
3×3×C_in×C_out + C_out输入 3 通道、输出 32 通道,就是 3×3×3×32 + 32 = 896 个。输入图像多大,都不影响这个数。
第一个坑是输出尺寸算错。公式是:
H_out = floor((H_in - K + 2P) / S) + 15×5 输入、3×3 核、padding 0、stride 1:(5-3)/1 + 1 = 3。忘了加 1,或者把 padding 算反,输出就会差一格。差一格不会报错,但后面的全连接层形状对不上,你会以为是别的地方错了。
第二个坑是把「逐元素乘」写成矩阵乘。卷积窗口和核是逐元素乘再求和,不是 @;x_patch @ k 要求形状满足矩阵乘规则,3×3 对 3×3 会得到一个 3×3,不是标量。
四、RNN 的箭头:同一组权重复用
<!-- 配图:x_t → h_t → y_t,h_t 自环标 W_hh -->
RNN 的折叠图只有一个隐藏节点,加一个自环。展开之后,同一个节点在时间轴上复制成了 h₀、h₁、h₂,但复制的是「计算」,不是「参数」。
单步公式:
h_t = tanh(W_xh x_t + W_hh h_(t-1) + b_h)
y_t = W_hy h_t + b_y拆开看形状。输入 100 维,隐藏 128 维,batch 是 2:
x_t: (100, 2)
W_xh: (128, 100)
W_hh: (128, 128)
h_t: (128, 2)
W_hy: (10, 128)
y_t: (10, 2)W_xh @ x_t 是 (128,100) @ (100,2) → (128,2),W_hh @ h_(t-1) 是 (128,128) @ (128,2) → (128,2),两个加起来再过 tanh,形状不变。
代码:
h = np.zeros((d_h, batch))
for t in range(T):
h = np.tanh(W_xh @ x[t] + W_hh @ h + b_h)
y[t] = W_hy @ h + b_y注意 h 在循环里被覆盖。第 t 步用的 h 是第 t-1 步算出来的,这就是「记忆」。参数 W_xh、W_hh、W_hy 在循环外定义,循环里一次都没改。
参数量:
W_xh: 100×128 + 128 = 12,928
W_hh: 128×128 + 128 = 16,512
W_hy: 128×10 + 10 = 1,290
合计 30,730序列从 10 步变成 100 步,还是 30,730。变的只有计算次数,不是参数数量。
第一个坑是乘法顺序。W_hh @ h 是 (128,128) @ (128,2),正确;h @ W_hh 是 (128,2) @ (128,128),形状不匹配。矩阵乘不满足交换律,谁在左边谁是「输出维度」。
第二个坑是时间维和 batch 维混了。x[t] 取的是第 t 个时间步,形状 (d_x, batch);如果你把数据准备成 (batch, T, d_x),就得先 transpose 再喂进去。这个错误不会在 RNN 里报,会一路传到 loss 才炸,排查起来很烦。
第三个坑是以为 for t in range(T) 是「RNN 的循环」。它只是把时间步展开写;真正的循环是权重共享——不管 t 是多少,用的都是同一组 W。把循环写成 100 个不同的 W,参数量立刻爆掉,那就不是 RNN 了。
五、GAN 的箭头:两个损失,两套参数
<!-- 配图:z → G → G(z);真实 x 与 G(z) 进 D;损失分两路更新 G 和 D -->
GAN 的图里,箭头互相指,最容易看晕。把它拆成两个损失就清楚了。
判别器的损失:
loss_D = -[ log D(x) + log(1 - D(G(z))) ]真实样本 x 的得分越接近 1,log D(x) 越接近 0;生成样本 G(z) 的得分越接近 0,log(1 - D(G(z))) 越接近 0。D 要最小化负号后面的东西,也就是最大化这两项。
生成器的损失,常用的是不饱和版本:
loss_G = -log D(G(z))G 想让 D(G(z)) 接近 1,所以最小化 -log D(G(z))。
两个损失对的是两套参数。更新 D 时,只动 D 的参数;更新 G 时,只动 G 的参数。写成一个训练循环:
for step in range(steps):
# 1) 更新判别器
real = sample_real(batch)
z = torch.randn(batch, dim)
fake = G(z).detach() # 关键:切断 G 的梯度
loss_D = -(torch.log(D(real)) + torch.log(1 - D(fake))).mean()
opt_D.zero_grad()
loss_D.backward()
opt_D.step()
# 2) 更新生成器
z = torch.randn(batch, dim)
fake = G(z)
for p in D.parameters():
p.requires_grad_(False) # 冻结 D
loss_G = -torch.log(D(fake)).mean()
opt_G.zero_grad()
loss_G.backward()
opt_G.step()
for p in D.parameters():
p.requires_grad_(True)第一个坑是忘了 detach()。更新 D 时,fake 是从 G 算出来的;如果不 detach,loss_D.backward() 会把梯度一路传回 G,D 的这一步就顺带改了 G 的参数。看起来也能跑,但两个网络互相污染,训练曲线会莫名其妙地抖。
第二个坑是把两个 loss 加起来一次 backward:
loss = loss_D + loss_G
loss.backward() # 错这样 D 和 G 的梯度混在一起,两个优化器都会拿到对方的梯度。正确做法是分开 backward、分开 step,中间清梯度。
第三个坑是 G 的损失用了饱和版本 log(1 - D(G(z)))。训练初期 D 很容易把真假分开,D(G(z)) 接近 0,log(1 - D(G(z))) 的梯度也接近 0,G 学不动。改用 -log D(G(z)),同样的最优解,梯度大得多。这个改动在原始 GAN 论文里就叫「non-saturating heuristic」,不是什么高级技巧,但很管用。
六、四张图的公式总表
把上面的拆解收成一张表。以后看到任何一张网络图,先找这四列。
| 网络 | 单步公式 | 参数 | 共享方式 | 输出形状 |
|---|---|---|---|---|
| DNN | z = Wx + b,a = σ(z) | W (out,in),b (out,) | 不共享 | (out, batch) |
| CNN | s = Σ x_patch ⊙ k + b | k (C_out,C_in,K,K),b | 核滑遍全图 | (C_out, H', W') |
| RNN | h_t = tanh(W_xh x_t + W_hh h_(t-1) + b) | W_xh、W_hh、W_hy、b | 沿时间复用 | (d_h, batch) |
| GAN | min_G max_D E[log D(x)] + E[log(1-D(G(z)))] | G 与 D 各自一套 | 各自内部共享 | 样本 / 概率 |
这张表里没有「学习」两个字。参数怎么从随机数变成有用的数,靠的是损失对参数的偏导,也就是反向传播。这一篇只写了前向;下一篇先把四张图用代码画出来,第 1 章再从最小的网络开始训,第 2 章把箭头反过来。
自检
- 给你一个 3-4-2 的 DNN,能算出 26 个参数吗?
- 给你一个 5×5 输入和 3×3 核,能算出输出是 3×3、参数量是 10 吗?
- 给你一个 100 维输入、128 维隐藏的 RNN,能算出 30,730 个参数吗?
- 能说出 GAN 训练时为什么必须
detach()和冻结 D 吗?
能答上三条,下一篇的画图代码你就能看懂每条线为什么画在那里。