Skip to content

案例拆解:一个神经元的手算全过程 ​

上一篇把「为什么需要隐藏层」讲完了:单层画不出 XOR,两个隐藏单元换一组坐标就能画出来。但那篇的算式里全是 0 和 1,是逻辑,不是真实的浮点数。

这一篇换个做法:不解释结构,只算账。从一个神经元的三个乘法开始,一路算到 10 个 logits 变成概率。每个数都用笔算得出来,每一步都能在代码里对上。

算完你会发现,所谓「深度学习的黑箱」,在单次前向这一侧其实没有黑箱:只有乘加、激活、softmax,三层运算,每一层都满足一条形状规则。真正黑的是这些权重怎么被训练出来——那是第 2 章的事。

一、一个神经元:三个乘法,一次加法,一次激活 ​

先看最小的单位。

![一个神经元的前向](/ai-four-kings/第 1 章:DNN 基础/assets/neuron_forward.svg)

<!-- 配图:x=(1,2,-1)、w=(0.2,-0.5,0.1)、b=0.3,z=-0.6,ReLU 后为 0 -->

图里的输入和权重是:

text
x₁ = 1.0    w₁ =  0.2
x₂ = 2.0    w₂ = −0.5
x₃ = −1.0   w₃ =  0.1
b  = 0.3

逐项算:

项算式结果
w₁x₁0.2 × 1.00.2
w₂x₂−0.5 × 2.0−1.0
w₃x₃0.1 × (−1.0)−0.1
乘积累加0.2 − 1.0 − 0.1−0.9
加偏置−0.9 + 0.3−0.6
ReLUmax(0, −0.6)0

所以这个神经元这一轮的输出是 0。它没有「觉得」什么,也没有「预测」什么,它就是被关掉了。

代码只有三行:

python
x = [1.0, 2.0, -1.0]
w = [0.2, -0.5, 0.1]
b = 0.3

z = sum(wi * xi for wi, xi in zip(w, x)) + b
a = max(0.0, z)
print(round(z, 4), round(a, 4))      # -0.6 0.0

这一轮「没输出」意味着什么 ​

如果这个单元在隐藏层,它的 0 会传给下一层:下一层的某个神经元收到的这一项就是 0,相当于这条连接这一轮不存在。

如果这个单元在输出层之前,问题更大。反向传播时,ReLU 在 z<0 处的导数是 0,所以:

text
∂L/∂z = ∂L/∂a × ReLU'(z) = ∂L/∂a × 0 = 0

再往下:

text
∂L/∂w₁ = ∂L/∂z × x₁ = 0
∂L/∂w₂ = ∂L/∂z × x₂ = 0
∂L/∂w₃ = ∂L/∂z × x₃ = 0
∂L/∂b  = ∂L/∂z      = 0

这一轮,这四个参数一个都没更新。不是更新得少,是精确的零。图里那句「这一轮神经元没输出」只是在描述现象;「∂L/∂z = 0,权重不更新」才是后果。

学习率调得再大也没用,因为乘数是 0。这就是第 3 章要处理的「死亡 ReLU」的种子:初始化偏了、学习率大了,一批单元会长期落在负半轴,怎么训都不动。

二、只改偏置,神经元从关到开 ​

![偏置把拐点挪了](/ai-four-kings/第 1 章:DNN 基础/assets/bias_shift.svg)

<!-- 配图:b=0 与 b=1 两条 ReLU,拐点从 0 移到 −1 -->

把 b 从 0.3 改成 1.0,输入和权重一个不动:

text
z = 0.2 − 1.0 − 0.1 + 1.0 = 0.1
a = max(0, 0.1) = 0.1

只挪了一个数,神经元从「完全没有输出」变成「输出 0.1」。这就是偏置的作用:权重决定输入的加权和有多大,偏置把这个和整体平移,从而决定 ReLU 的拐点落在哪里。

用图里的说法:没有 b,所有 ReLU 的拐点都卡在 z=0;有了 b,每个单元可以把拐点挪到 z = −b/w 的位置。一层 256 个单元,如果 b 全是 0,它们只能在同一个位置折;b 各不相同,折角才能铺开。

这也是为什么参数表里每一层都有偏置。偏置的数量等于输出维度:4 个输出单元,就有 4 个偏置。

三、一层:把四个神经元合成一次矩阵乘 ​

一个神经元是一次点积。一层 4 个神经元,如果写四个点积,代码里就是四段几乎一样的循环。实际实现不会这么写,因为四个点积可以合成一次矩阵乘。

![一层 = 一次矩阵乘](/ai-four-kings/第 1 章:DNN 基础/assets/matrix_shapes.svg)

<!-- 配图:W(4,3) @ x(3,2) → z(4,2),b 按列广播 -->

假设输入 3 维,这一层 4 个神经元,一次喂 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),中间两个 3 抵消,剩下 (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,)

z = [[sum(W[r][c] * x[c][k] for c in range(3)) + b[r]
      for k in range(2)] for r in range(4)]

print(z)
# [[1.4, 1.7], [4.2, 5.4], [7.0, 9.1], [9.8, 12.8]]

逐个元素验证第一列(第 1 条样本,x 列是 [1, 3, 5]):

输出算式结果
z₁₁0.0×1 + 0.1×3 + 0.2×5 + 0.11.4
z₂₁0.3×1 + 0.4×3 + 0.5×5 + 0.24.2
z₃₁0.6×1 + 0.7×3 + 0.8×5 + 0.37.0
z₄₁0.9×1 + 1.0×3 + 1.1×5 + 0.49.8

第二列换成 [2, 4, 6],同一套 W、同一套 b,得到 [1.7, 5.4, 9.1, 12.8]。注意第 2 列每个数都比第 1 列大 0.3 左右——因为输入整体大了 1,而每行权重之和约为 0.3–3.0,所以放大倍数不同。

这里能看出矩阵乘的两条「共享」:

  • 四条输出共用同一列输入 x,区别只在 W 的每一行;
  • 两条样本共用同一套 W 和 b,区别只在 x 的每一列。

这就是全连接层的全部内容。没有别的魔法。

三个形状坑 ​

坑一:乘法顺序。 W @ x 是 (4,3) @ (3,2) → (4,2);x @ W 是 (3,2) @ (4,3),中间 2 和 4 对不上,直接报错。矩阵乘不满足交换律,谁在左边谁决定输出的行数。

坑二:batch 维放哪。 这篇为了下标直观,把特征放前、样本放后,得到 (3,2)。PyTorch 的 nn.Linear 吃的是 (batch, in_features),也就是 (2,3),内部会做 x @ W.T + b。两种写法都对,但不要混:混着写,bug 不会在这一层报,会在一路 reshape 之后的 loss 里报。

坑三:偏置的形状。 手写循环里 b[r] 是标量,(4,) 够用。换到 NumPy,z 是 (4,2),b 是 (4,),广播会把 (4,) 当成行向量,和 (4,2) 的最后一维 2 对不上,报错。要么写 b[:, None],要么把数据转成 (2,3) 再 x @ W.T + b。

如果某个权重是负的 ​

把 W 第一行第一列从 0.0 改成 −3.0,再算 z₁₁:

text
z₁₁ = −3.0×1 + 0.1×3 + 0.2×5 + 0.1 = −1.6
a₁₁ = max(0, −1.6) = 0

形状完全没变,但第一条样本的第一个输出单元被关掉了。一层 4 个单元,关掉一个,还剩 3 个。关掉的比例取决于权重和输入,不取决于代码写错没错。

四、batch:为什么不用写 for 循环 ​

![batch 就是并排](/ai-four-kings/第 1 章:DNN 基础/assets/batch_stack.svg)

<!-- 配图:两条样本并排成 (3,2),一次矩阵乘得到 (4,2) -->

上面的 x 有两列,就是 batch=2。如果 batch=64,x 是 (3,64),z 是 (4,64)。矩阵乘的形状规则不变:

text
(4,3) @ (3,64) → (4,64)

计算量恰好是 2 条样本的 32 倍:4×3×64 = 768 次乘加,对比 4×3×2 = 24。参数还是 16 个,一个没多。

这就是 batch 的全部意义:把多条样本排成矩阵的列,让一次矩阵乘同时算完。写成循环是等价的:

python
z = [[0.0] * 2 for _ in range(4)]
for k in range(2):
    for r in range(4):
        z[r][k] = sum(W[r][c] * x[c][k] for c in range(3)) + b[r]

结果和向量化版本一模一样。区别只在速度:Python 循环每条样本都要重新解释一遍下标,矩阵乘交给 BLAS,一次调用吃满 CPU 的 SIMD 和缓存。

「batch 不影响参数量」这句话要记牢。后面调 batch size 调的是显存和梯度噪声,不是模型大小。

五、参数账:从 26 到 203,530 ​

![3-4-2 网络的 26 个参数](/ai-four-kings/第 1 章:DNN 基础/assets/param_count.svg)

<!-- 配图:3-4-2 全连接,每条线一个权重,26 个参数 -->

把上面的 3→4 层接一个 4→2 的输出层,就是图里的 3-4-2 网络:

text
第一层:3×4 + 4 = 16
第二层:4×2 + 2 = 10
合计:26

其中权重 20 个(12 + 8),偏置 6 个(4 + 2)。图上每条连线是一个权重,每个圆圈里那个单元自带一个偏置。

换成本章的真实网络:

text
784 → 256 → 10
第一层:784×256 + 256 = 200,960
第二层:256×10 + 10   =   2,570
合计:203,530

再加一层 256→128:

text
784→256:200,960
256→128: 32,896
128→10 :  1,290
合计:235,146

多一层只多 31,616 个参数(+15.5%),但多了一次 ReLU,多了一层折角组合。这就是「加深比加宽划算」在参数账上的样子。

参数账只有一条规则:

text
第 l 层参数 = 输入宽度 × 输出宽度 + 输出宽度

Flatten 没有参数,ReLU 没有参数,softmax 没有参数,交叉熵没有参数。整个网络的参数量就是所有 nn.Linear 的 weight.numel() + bias.numel() 之和。实战篇会用一行代码验证这个数:

python
sum(p.numel() for p in model.parameters())   # 203530

如果算出来不是 203,530,先检查是不是把 Flatten 当成了可学习层,或者把 softmax 写成了 nn.Linear。

六、计算量:203,264 次乘加花在哪 ​

![每样本乘加次数](/ai-four-kings/第 1 章:DNN 基础/assets/flops.svg)

<!-- 配图:784→256 占 200,704 次,256→10 只占 2,560 次 -->

参数量是存量,计算量是流量。一次前向的乘加次数(MAC):

text
784×256 = 200,704
256×10  =   2,560
合计    203,264 MAC/样本

第一层占了 200,704 / 203,264 = 98.74%。输出层 10 个类别,只花 2,560 次,连零头都不到。

放大到 batch 和一个 epoch:

text
batch 64:203,264 × 64 = 13,008,896 ≈ 1,300 万 MAC
epoch 10,000 样本:203,264 × 10,000 = 2,032,640,000 ≈ 20.3 亿 MAC

听起来很大,但矩阵乘是 BLAS 最擅长的形状。这次实测(Apple Silicon CPU、4 线程)每个 epoch 0.31–0.35 秒,5 个 epoch 不到 2 秒。20 亿次乘加压进 0.3 秒,靠的就是把 784×256 的权重和 64 列的输入一次性铺进缓存。

显存这边,float32 下:

text
参数:203,530 × 4 B = 814,120 B ≈ 795 KiB
梯度:同样一份
Adam 一阶矩:同样一份
Adam 二阶矩:同样一份
合计约 3.2 MiB

真正的大头是激活值。batch 64 时:

text
输入:64 × 784 × 4 B = 200,704 B ≈ 196 KiB
隐藏:64 × 256 × 4 B =  65,536 B =  64 KiB

加起来不到 300 KiB。所以这个小网络在 CPU 上跑,内存完全不是瓶颈。

把同样的账搬到 224×224×3 的图像上,接一层 1000 维全连接:

text
150,528 × 1000 + 1000 = 150,529,000 个参数
float32:约 574 MiB
训练状态四份:约 2.3 GiB

参数账在图像上会迅速失控。第 4 章的 CNN 就是来收拾这笔账的。

七、激活函数:同一个 z,三种命运 ​

![三种激活的数值表](/ai-four-kings/第 1 章:DNN 基础/assets/activation_values.svg)

<!-- 配图:z 从 −3 到 3,ReLU / Sigmoid / Tanh 的数值表 -->

把 z 取五个值,同时算激活值和导数:

zReLUReLU'SigmoidSigmoid'TanhTanh'
−3.0000.04740.0452−0.99510.0099
−0.6000.35430.2288−0.53700.7116
0.0000.50000.25000.00001.0000
0.60.610.64570.22880.53700.7116
3.03.010.95260.04520.99510.0099

这张表比任何曲线图都直接。三个结论:

第一,ReLU 在负半轴是精确的 0,不是「接近 0」。 z=−0.6 时,ReLU 的导数是 0,Sigmoid 的导数是 0.2288。所以「死掉的 ReLU」真的收不到任何梯度,而 Sigmoid 在同样的位置还能学,只是慢。

第二,Sigmoid 和 Tanh 的导数在两端会塌。 z=3 时,Sigmoid 的导数只剩 0.0452,Tanh 只剩 0.0099。五层这样的激活连乘:

text
Sigmoid:0.0452⁵ ≈ 1.9 × 10⁻⁷
Tanh:   0.0099⁵ ≈ 9.5 × 10⁻¹⁰
ReLU 正半轴:1⁵ = 1

梯度还没传到第一层,就已经变成浮点数的噪声了。这就是梯度消失的算术版本,第 2 章会用数值实验复现。

第三,ReLU 的正半轴导数是 1,不放大也不缩小。 这是这一章选 ReLU 的唯一理由:它不给反向传播添乘数。代价是负半轴直接归零,需要靠初始化和小学习率来避免大面积死亡。

注意表里没有「输出层激活」。网络的最后一层是 Linear(256, 10),直接吐 10 个 logits,不接 ReLU、不接 Sigmoid。因为 PyTorch 的 nn.CrossEntropyLoss 内部已经把 log_softmax 和负对数似然合在一起了;你在网络末尾再加一个 nn.Softmax,等于做了两次 softmax,梯度会被压扁,训练变慢。

八、输出层:10 个 logits 怎么变成 98.73% ​

![softmax 把 logits 变成概率](/ai-four-kings/第 1 章:DNN 基础/assets/softmax.svg)

<!-- 配图:logits 经 exp、归一化得到三个概率,和为 1 -->

softmax 的定义:

text
pᵢ = exp(zᵢ) / Σⱼ exp(zⱼ)

拿真实模型的一张测试图来算。这张图的标签是 4,模型输出的 10 个 logits 是:

text
z = [−6.8564, −6.5353, −4.6914, −3.7176, 6.6028,
     −2.5948, −3.9363, 1.2287, −3.5173, 1.7785]

第一步:减去最大值。 直接算 exp(6.6028) 还好,但遇到 logits 到 1000 就会溢出。标准做法是先减 m = max(z) = 6.6028:

text
z − m = [−13.4592, −13.1381, −11.2942, −10.3204, 0,
          −9.1976, −10.5391, −5.3741, −10.1201, −4.8243]

减一个常数不改变 softmax 的结果,因为分子分母同乘了 exp(−m),约掉了。

第二步:取指数。

text
exp(z − m) = [0.000001, 0.000002, 0.000012, 0.000033, 1.000000,
              0.000101, 0.000026, 0.004635, 0.000040, 0.008032]

第三步:归一化。 加起来:

text
sum = 1.012884

除以总和:

类别logitexp(z−m)概率
46.60281.0000000.9873
91.77850.0080320.0079
71.22870.0046350.0046
5−2.59480.0001010.0001
其余< −3.5< 0.00004≈ 0

模型给 4 的概率是 98.73%,和 metrics.json 里的 0.9873 对得上。

第四步:交叉熵。 真实标签是 4,损失只看正确类的那一项:

text
L = −log p₄ = −log 0.9873 = 0.0128

这个 0.0128 比 5 轮训练后的平均测试损失 0.1483 小一个数量级。它是一张「容易」的图。

两个值得记住的比例 ​

logit 4 和 logit 7 的差:

text
6.6028 − 1.2287 = 5.3741

概率比:

text
0.9873 / 0.0046 ≈ 214.6

指数把 5.37 的差值放大成了 215 倍的概率差。logit 4 和 logit 5 的差是 9.1976,概率比约 9,900 倍。

所以 softmax 不看绝对大小,只看相对差距。把 10 个 logits 全部加 100,概率一点不变;把最大的那个再抬高 1,它的概率会明显变大。这也是「模型置信度」的来源:不是它「知道」,是 logit 之间的差距大。

九、同一个账本,算一个错例 ​

模型在 2,000 张测试图上错了 91 张,错误率 4.55%。挑第一张错例,真实标签 8,预测 4:

text
z = [−9.6890, −4.2342, −7.9816, −2.7779, 5.0841,
     1.3367, −4.4515, −4.4274, 3.0303, 1.5378]

同样的三步:

text
p₄ = 0.8465
p₈ = 0.1086
p₉ = 0.0244
p₅ = 0.0200
其余 < 0.001

模型的排序是 4 > 8 > 9 > 5。它没有「完全不会」,它给真类 8 留了 10.9% 的概率,只是把 4 排在了第一。

这张图的交叉熵:

text
L = −log 0.1086 = 2.2201

是上一张正确图的 173 倍。训练时,平均损失就是被这类样本拉高的:91 个错例每个贡献 2 以上,1,909 个正确样本大多贡献 0.0x。

错在哪一步 ​

前向的每一步都没有错:形状对、矩阵乘对、softmax 对。错的是权重。这张 8 的上半圈写得窄,左侧笔画和 4 的斜线在 784 维空间里离得很近;两层线性模型只能在这个位置上画一条边界,它把这张图划到了 4 那边。

看概率而不是只看 argmax,能看到「犹豫」:84.65% 对 10.86%,差距没有正确样本那么悬殊。这也解释了为什么仅看准确率不够——模型可能在一批样本上「差一点」,在另一批上「错得离谱」,平均损失会把两者分开。

十、手算能查到哪一步 ​

把这一篇的账收一下。给你任意一个前向,下面这些都能用纸笔验证:

能手算怎么算这一篇的例子
形状相邻矩阵的行列抵消(4,3)@(3,2)→(4,2)
参数量输入宽×输出宽+输出宽26、203,530、235,146
计算量每层输入宽×输出宽求和203,264 MAC/样本
激活值代 z 进函数z=−0.6 → ReLU 0、Sigmoid 0.3543
概率exp 后归一化0.9873
单样本损失−log p_y0.0128、2.2201
单条梯度上游梯度 × 本地导数∂L/∂z = 0(ReLU 负半轴)

不能手算的也有三样:

  • 训练后的 203,530 个权重。它们不是推出来的,是 5 个 epoch、780 次参数更新迭代出来的。
  • 泛化误差。2,000 张测试图上的 4.55% 是跑出来的,不是算出来的。
  • 优化轨迹。损失从 0.4914 降到 0.0816,中间每一步走多远,取决于 Adam 的状态,纸上推不出来。

分工很清楚:结构和单次前向可以手算,学习和泛化必须靠代码。

下一篇把这套账写成脚本:搭一个 784→256→10 的 MLP,在 MNIST 子集上真的训 5 轮,把这一篇的每个数字变成日志里的一行。还会故意做三次失败实验——全零初始化、学习率 5.0、标签整体加 1——让你看到这些数字怎么坏掉。

自检 ​

  • 给 x=(1,2,−1)、w=(0.2,−0.5,0.1)、b=0.3,能算出 z=−0.6、a=0 吗?把 b 改成 1.0 呢?
  • 给 W(4,3)、x(3,2)、b(4,1),能不查表写出 z 的形状,并手算 z₁₁ 吗?
  • 3-4-2 网络多少个参数?784-256-10 呢?多一层 256→128 呢?
  • 为什么第一层占了 98.74% 的计算量,输出层只占 1.26%?
  • 同一组 logits,为什么减去最大值之后 softmax 不变?
  • 一张图 p₄=0.9873,另一张 p₄=0.8465。哪张的交叉熵更大?大多少?

答不上来的那一条,回到对应小节,把数重新代一遍。下一篇会用代码把这些数全部跑出来,你可以一边跑一边对。