案例拆解:一个神经元的手算全过程
上一篇把「为什么需要隐藏层」讲完了:单层画不出 XOR,两个隐藏单元换一组坐标就能画出来。但那篇的算式里全是 0 和 1,是逻辑,不是真实的浮点数。
这一篇换个做法:不解释结构,只算账。从一个神经元的三个乘法开始,一路算到 10 个 logits 变成概率。每个数都用笔算得出来,每一步都能在代码里对上。
算完你会发现,所谓「深度学习的黑箱」,在单次前向这一侧其实没有黑箱:只有乘加、激活、softmax,三层运算,每一层都满足一条形状规则。真正黑的是这些权重怎么被训练出来——那是第 2 章的事。
一、一个神经元:三个乘法,一次加法,一次激活
先看最小的单位。

<!-- 配图:x=(1,2,-1)、w=(0.2,-0.5,0.1)、b=0.3,z=-0.6,ReLU 后为 0 -->
图里的输入和权重是:
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.0 | 0.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 |
| ReLU | max(0, −0.6) | 0 |
所以这个神经元这一轮的输出是 0。它没有「觉得」什么,也没有「预测」什么,它就是被关掉了。
代码只有三行:
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,所以:
∂L/∂z = ∂L/∂a × ReLU'(z) = ∂L/∂a × 0 = 0再往下:
∂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」的种子:初始化偏了、学习率大了,一批单元会长期落在负半轴,怎么训都不动。
二、只改偏置,神经元从关到开

<!-- 配图:b=0 与 b=1 两条 ReLU,拐点从 0 移到 −1 -->
把 b 从 0.3 改成 1.0,输入和权重一个不动:
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 个神经元,如果写四个点积,代码里就是四段几乎一样的循环。实际实现不会这么写,因为四个点积可以合成一次矩阵乘。

<!-- 配图:W(4,3) @ x(3,2) → z(4,2),b 按列广播 -->
假设输入 3 维,这一层 4 个神经元,一次喂 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),中间两个 3 抵消,剩下 (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,)
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.1 | 1.4 |
| z₂₁ | 0.3×1 + 0.4×3 + 0.5×5 + 0.2 | 4.2 |
| z₃₁ | 0.6×1 + 0.7×3 + 0.8×5 + 0.3 | 7.0 |
| z₄₁ | 0.9×1 + 1.0×3 + 1.1×5 + 0.4 | 9.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₁₁:
z₁₁ = −3.0×1 + 0.1×3 + 0.2×5 + 0.1 = −1.6
a₁₁ = max(0, −1.6) = 0形状完全没变,但第一条样本的第一个输出单元被关掉了。一层 4 个单元,关掉一个,还剩 3 个。关掉的比例取决于权重和输入,不取决于代码写错没错。
四、batch:为什么不用写 for 循环

<!-- 配图:两条样本并排成 (3,2),一次矩阵乘得到 (4,2) -->
上面的 x 有两列,就是 batch=2。如果 batch=64,x 是 (3,64),z 是 (4,64)。矩阵乘的形状规则不变:
(4,3) @ (3,64) → (4,64)计算量恰好是 2 条样本的 32 倍:4×3×64 = 768 次乘加,对比 4×3×2 = 24。参数还是 16 个,一个没多。
这就是 batch 的全部意义:把多条样本排成矩阵的列,让一次矩阵乘同时算完。写成循环是等价的:
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 个参数 -->
把上面的 3→4 层接一个 4→2 的输出层,就是图里的 3-4-2 网络:
第一层:3×4 + 4 = 16
第二层:4×2 + 2 = 10
合计:26其中权重 20 个(12 + 8),偏置 6 个(4 + 2)。图上每条连线是一个权重,每个圆圈里那个单元自带一个偏置。
换成本章的真实网络:
784 → 256 → 10
第一层:784×256 + 256 = 200,960
第二层:256×10 + 10 = 2,570
合计:203,530再加一层 256→128:
784→256:200,960
256→128: 32,896
128→10 : 1,290
合计:235,146多一层只多 31,616 个参数(+15.5%),但多了一次 ReLU,多了一层折角组合。这就是「加深比加宽划算」在参数账上的样子。
参数账只有一条规则:
第 l 层参数 = 输入宽度 × 输出宽度 + 输出宽度Flatten 没有参数,ReLU 没有参数,softmax 没有参数,交叉熵没有参数。整个网络的参数量就是所有 nn.Linear 的 weight.numel() + bias.numel() 之和。实战篇会用一行代码验证这个数:
sum(p.numel() for p in model.parameters()) # 203530如果算出来不是 203,530,先检查是不是把 Flatten 当成了可学习层,或者把 softmax 写成了 nn.Linear。
六、计算量:203,264 次乘加花在哪

<!-- 配图:784→256 占 200,704 次,256→10 只占 2,560 次 -->
参数量是存量,计算量是流量。一次前向的乘加次数(MAC):
784×256 = 200,704
256×10 = 2,560
合计 203,264 MAC/样本第一层占了 200,704 / 203,264 = 98.74%。输出层 10 个类别,只花 2,560 次,连零头都不到。
放大到 batch 和一个 epoch:
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 下:
参数:203,530 × 4 B = 814,120 B ≈ 795 KiB
梯度:同样一份
Adam 一阶矩:同样一份
Adam 二阶矩:同样一份
合计约 3.2 MiB真正的大头是激活值。batch 64 时:
输入: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 维全连接:
150,528 × 1000 + 1000 = 150,529,000 个参数
float32:约 574 MiB
训练状态四份:约 2.3 GiB参数账在图像上会迅速失控。第 4 章的 CNN 就是来收拾这笔账的。
七、激活函数:同一个 z,三种命运

<!-- 配图:z 从 −3 到 3,ReLU / Sigmoid / Tanh 的数值表 -->
把 z 取五个值,同时算激活值和导数:
| z | ReLU | ReLU' | Sigmoid | Sigmoid' | Tanh | Tanh' |
|---|---|---|---|---|---|---|
| −3.0 | 0 | 0 | 0.0474 | 0.0452 | −0.9951 | 0.0099 |
| −0.6 | 0 | 0 | 0.3543 | 0.2288 | −0.5370 | 0.7116 |
| 0.0 | 0 | 0 | 0.5000 | 0.2500 | 0.0000 | 1.0000 |
| 0.6 | 0.6 | 1 | 0.6457 | 0.2288 | 0.5370 | 0.7116 |
| 3.0 | 3.0 | 1 | 0.9526 | 0.0452 | 0.9951 | 0.0099 |
这张表比任何曲线图都直接。三个结论:
第一,ReLU 在负半轴是精确的 0,不是「接近 0」。 z=−0.6 时,ReLU 的导数是 0,Sigmoid 的导数是 0.2288。所以「死掉的 ReLU」真的收不到任何梯度,而 Sigmoid 在同样的位置还能学,只是慢。
第二,Sigmoid 和 Tanh 的导数在两端会塌。 z=3 时,Sigmoid 的导数只剩 0.0452,Tanh 只剩 0.0099。五层这样的激活连乘:
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%

<!-- 配图:logits 经 exp、归一化得到三个概率,和为 1 -->
softmax 的定义:
pᵢ = exp(zᵢ) / Σⱼ exp(zⱼ)拿真实模型的一张测试图来算。这张图的标签是 4,模型输出的 10 个 logits 是:
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:
z − m = [−13.4592, −13.1381, −11.2942, −10.3204, 0,
−9.1976, −10.5391, −5.3741, −10.1201, −4.8243]减一个常数不改变 softmax 的结果,因为分子分母同乘了 exp(−m),约掉了。
第二步:取指数。
exp(z − m) = [0.000001, 0.000002, 0.000012, 0.000033, 1.000000,
0.000101, 0.000026, 0.004635, 0.000040, 0.008032]第三步:归一化。 加起来:
sum = 1.012884除以总和:
| 类别 | logit | exp(z−m) | 概率 |
|---|---|---|---|
| 4 | 6.6028 | 1.000000 | 0.9873 |
| 9 | 1.7785 | 0.008032 | 0.0079 |
| 7 | 1.2287 | 0.004635 | 0.0046 |
| 5 | −2.5948 | 0.000101 | 0.0001 |
| 其余 | < −3.5 | < 0.00004 | ≈ 0 |
模型给 4 的概率是 98.73%,和 metrics.json 里的 0.9873 对得上。
第四步:交叉熵。 真实标签是 4,损失只看正确类的那一项:
L = −log p₄ = −log 0.9873 = 0.0128这个 0.0128 比 5 轮训练后的平均测试损失 0.1483 小一个数量级。它是一张「容易」的图。
两个值得记住的比例
logit 4 和 logit 7 的差:
6.6028 − 1.2287 = 5.3741概率比:
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:
z = [−9.6890, −4.2342, −7.9816, −2.7779, 5.0841,
1.3367, −4.4515, −4.4274, 3.0303, 1.5378]同样的三步:
p₄ = 0.8465
p₈ = 0.1086
p₉ = 0.0244
p₅ = 0.0200
其余 < 0.001模型的排序是 4 > 8 > 9 > 5。它没有「完全不会」,它给真类 8 留了 10.9% 的概率,只是把 4 排在了第一。
这张图的交叉熵:
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_y | 0.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。哪张的交叉熵更大?大多少?
答不上来的那一条,回到对应小节,把数重新代一遍。下一篇会用代码把这些数全部跑出来,你可以一边跑一边对。