Skip to content

案例拆解:每个箭头对应哪行公式 ​

上一篇把四张图看成了四种连接方式。这一篇换个看法:把图放大到一条箭头,问它到底在算什么。

一张图里最容易被忽略的是箭头方向。前向时,箭头是数据流:输入从左边进去,一层层变形状,最后变成输出。反向时,箭头倒着走,传的是梯度:损失对每个参数的偏导,从右边一路退回来。

这一篇只拆前向。反向留到第 2 章,到时候你会发现,每条前向箭头都对应一条反向箭头,公式还是那几行,只是乘的顺序反了。

一个神经元

<!-- 配图:三个输入、三个权重、一个求和节点、一个 ReLU、一个输出 -->

一、一个神经元:一次点积,一次激活 ​

先看最小的单位。图里有三个输入 x₁、x₂、x₃,三条箭头标着 w₁、w₂、w₃,中间一个 Σ,右边一个 ReLU。

别把它当成抽象符号,代几个数进去。

text
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) = 0

z 是 -0.6,ReLU 把它压成 0。这个神经元这一轮没有输出。

如果把偏置 b 从 0.3 改成 1.0,z 就变成 0.1,a 就是 0.1。输入和权重一个没动,只挪了偏置,神经元从「死」变成「活」。

这就是偏置的作用:它不是可有可无的截距,它把激活函数的阈值挪了。没有 b,ReLU 的拐点永远在 0;有了 b,拐点可以移到任意位置。

写成代码只有两行:

python
z = sum(w[i] * x[i] for i in range(3)) + b
a = max(0, z)

如果 x 和 w 是 NumPy 数组,第一行就是 z = w @ x + b。@ 是点积,不是矩阵乘的语法糖——三个乘加一次算完。

前向到这里就结束了。但图上那条 w₁ 的箭头,在反向时还要再走一遍:

text
∂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 条样本:

text
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),按列广播到两条样本。

代码(零依赖,和实战篇保持同一套环境):

python
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 不是一回事:

text
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 矩阵。取左上角窗口:

text
窗口:          核:          逐元素乘:
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 = 411

9 次乘法,8 次加法,再加一个偏置,就是一个输出值。窗口往右挪一格,再来一次;挪完一行,往下走一行。5×5 的输入、3×3 的核、步长 1,输出是 3×3。

写成代码:

python
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 个。

多通道时,参数量是:

text
3×3×C_in×C_out + C_out

输入 3 通道、输出 32 通道,就是 3×3×3×32 + 32 = 896 个。输入图像多大,都不影响这个数。

第一个坑是输出尺寸算错。公式是:

text
H_out = floor((H_in - K + 2P) / S) + 1

5×5 输入、3×3 核、padding 0、stride 1:(5-3)/1 + 1 = 3。忘了加 1,或者把 padding 算反,输出就会差一格。差一格不会报错,但后面的全连接层形状对不上,你会以为是别的地方错了。

第二个坑是把「逐元素乘」写成矩阵乘。卷积窗口和核是逐元素乘再求和,不是 @;x_patch @ k 要求形状满足矩阵乘规则,3×3 对 3×3 会得到一个 3×3,不是标量。

四、RNN 的箭头:同一组权重复用 ​

RNN 折叠画法

<!-- 配图:x_t → h_t → y_t,h_t 自环标 W_hh -->

RNN 的折叠图只有一个隐藏节点,加一个自环。展开之后,同一个节点在时间轴上复制成了 h₀、h₁、h₂,但复制的是「计算」,不是「参数」。

单步公式:

text
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:

text
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,形状不变。

代码:

python
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 在循环外定义,循环里一次都没改。

参数量:

text
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 的箭头:两个损失,两套参数 ​

GAN 双网络

<!-- 配图:z → G → G(z);真实 x 与 G(z) 进 D;损失分两路更新 G 和 D -->

GAN 的图里,箭头互相指,最容易看晕。把它拆成两个损失就清楚了。

判别器的损失:

text
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 要最小化负号后面的东西,也就是最大化这两项。

生成器的损失,常用的是不饱和版本:

text
loss_G = -log D(G(z))

G 想让 D(G(z)) 接近 1,所以最小化 -log D(G(z))。

两个损失对的是两套参数。更新 D 时,只动 D 的参数;更新 G 时,只动 G 的参数。写成一个训练循环:

python
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:

python
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」,不是什么高级技巧,但很管用。

六、四张图的公式总表 ​

把上面的拆解收成一张表。以后看到任何一张网络图,先找这四列。

网络单步公式参数共享方式输出形状
DNNz = Wx + b,a = σ(z)W (out,in),b (out,)不共享(out, batch)
CNNs = Σ x_patch ⊙ k + bk (C_out,C_in,K,K),b核滑遍全图(C_out, H', W')
RNNh_t = tanh(W_xh x_t + W_hh h_(t-1) + b)W_xh、W_hh、W_hy、b沿时间复用(d_h, batch)
GANmin_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 吗?

能答上三条,下一篇的画图代码你就能看懂每条线为什么画在那里。