刚开始学深度学习的时候,我和很多人一样,能跑框架里现成的模型,但对“模型到底是怎么学会的”始终隔着一层。后来我强迫自己在MNIST上用纯NumPy手写一个两层全连接网络,把前向传播和反向传播每一步都推一遍,很多模糊的概念才真正落地。这个系列前面两篇已经聊过数据读取和基础网络骨架,今天这篇正好卡在最核心的地方:前向传播和反向传播。
我会从网络结构设计开始,把每一层的矩阵运算是怎么回事、损失函数的梯度怎么一层层传回去,用可运行的代码、手算推导和实际踩坑讲清楚。这篇内容适合两类人:一类是想弄明白框架背后原理的初学者,另一类是准备面试、需要手推反向传播的人。看完之后你至少能回答两个问题:为什么Softmax和交叉熵放在一起梯度会变成p-y?为什么反向传播要用W.T去乘梯度?
1. 先想清楚网络结构和每一层的维度:一个能跑通的两层全连接网络
1.1 为什么是“输入784 -> 隐藏层64 -> 输出10”
MNIST里的每张图是28×28的灰度图,展平之后就是784个浮点数。输入维度定为784,是因为我们不想在处理图像时先引入卷积这类复杂结构,单纯用全连接层就能验证前向和反向传播的核心逻辑。输出维度是10,因为要做10个数字的分类。中间加一个隐藏层,是为了让模型具备非线性表达能力。
隐藏层取64不是拍脑袋,它是强度和计算量的一个平衡点。如果隐藏层太小,比如只有16个神经元,模型的表达能力会明显打折扣,准确率上不去;如果太大会拖慢训练,而且在这个只有784个输入的小任务里没必要。在NumPy手写实现的场景下,64在速度和效果上都比较舒服,能轻松在验证集上跑到95%以上。
有一个很容易被忽略但非常关键的点:网络里每一层的矩阵维度都必须是自洽的。我习惯把输入X看成[batch, 784]的行向量形式,这样一层线性变换就是X @ W + b,得到的形状是[batch, hidden]。如果中间某一步维度写反了,后面所有计算都会乱掉。
下面这张表把变量形状全部列清楚,建议你写代码时放在手边:
| 变量 | 形状 | 含义 |
|---|---|---|
| X | [batch, 784] | 一个批次的输入 |
| W1 | [784, 64] | 输入层到隐藏层的权重 |
| b1 | [64] | 隐藏层偏置 |
| z1 | [batch, 64] | 隐藏层线性输出 |
| a1 | [batch, 64] | 隐藏层ReLU激活输出 |
| W2 | [64, 10] | 隐藏层到输出层的权重 |
| b2 | [10] | 输出层偏置 |
| z2 | [batch, 10] | 输出层线性输出(logits) |
| y_pred | [batch, 10] | Softmax后的概率 |
| y_true | [batch] 或 [batch, 10] | 标签或One-hot标签 |
这里约定W的形状是[输入维度, 输出维度]。有教程会写成[输出维度, 输入维度],然后用W @ X,那种写法也可以用,但前向和反向公式里的转置位置全都要跟着变。我建议你选定一种就不要再切换,否则反向传播推导很快会乱。
1.2 权重初始化:不是随便填个随机数就完事
很多初学者直接np.random.randn(784, 64)初始化所有权重,然后训练就爆炸了。问题在于randn生成的是均值为0、方差为1的标准正态分布,当输入是784维时,线性层输出的方差会被放大到接近784的量级。这么大的数值进入ReLU,经过多层以后要么梯度爆炸要么梯度消失。
我在这类小项目里常用He初始化:np.random.randn(784, 64) * np.sqrt(2 / 784)。它的核心思想是根据输入维度来缩放方差,让每一层输出的方差保持在合理范围内。隐藏层到输出层的权重,W2就用np.sqrt(2 / 64)缩放。偏置b1和b2直接初始化为0是可以的,因为对称性问题主要由权重解决。
如果觉得He初始化太麻烦,也可以先用0.01 * np.random.randn(784, 64)。小数值随机初始化在这个两层小网络中也能跑,只是收敛速度会慢一些。切记不要全零初始化。全零会让所有隐藏神经元得到完全相同的梯度和更新,等于所有神经元都退化成同一个,模型完全没有表达能力。
1.3 用代码把结构和参数固定下来
把参数放在一个字典里,后续更新会方便一些:
def init_params(input_dim=784, hidden_dim=64, output_dim=10): np.random.seed(42) W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2 / input_dim) b1 = np.zeros(hidden_dim) W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2 / hidden_dim) b2 = np.zeros(output_dim) return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}注意这里用了固定随机种子,是为了每次跑出的结果可复现。你调试时可以随时用中间变量打印维度,比如打印X @ W1的形状,如果和你预期不符就马上改,不要等到训练时再排查。
2. 前向传播:把矩阵乘法和激活函数当乐高搭起来
2.1 线性层加ReLU:让网络拥有非线性能力
前向传播的本质就是数据从输入一层层往后流。以我的经验,最好把“线性变换”和“非线性激活”分两步看。先做z1 = X @ W1 + b1,这一步是线性操作;再对z1做a1 = np.maximum(0, z1),这是ReLU激活。
为什么要加非线性激活?因为如果没有激活函数,两个线性层叠加起来仍然是线性变换,无论堆多少层都等价于一层线性模型,根本学不了复杂决策边界。ReLU在NumPy里写起来非常干净:np.maximum(0, z1)。它的导数也很干净:当z1大于0时导数是1,否则是0。这个性质让反向传播非常直观。
实践中还遇到过一个问题:如果初始化范围太大,ReLU会让很大一部分神经元输出恒为0,这些“死神经元”的梯度永远是0,反向传播很难再更新它们。这也是我前文坚持先用He初始化或者小随机初始化的原因。
2.2 Softmax和交叉熵为什么是固定搭配
输出层得到z2 = a1 @ W2 + b2之后,还不能直接用。z2是10个实数,可以被视为“打分”。为了把它变成“10个数字分别是什么概率”,就需要Softmax:
exp(z2_i) / sum(exp(z2_k))Softmax会把每个打分变成正数并让总和等于1,这样网络输出就有了概率含义。
损失函数选用交叉熵,原因在于它和Softmax是天生一对。交叉熵衡量真实分布和预测分布之间的差异:-sum(y_onehot * log(p))。因为真实标签是One-hot的,真正起作用的只有真实类别那一项。对第i个样本,损失就是-log(p[i, y[i]]),也就是让正确类别的概率尽量大。
这一对搭配在反向传播中有一个很漂亮的化简,我放到下一章详细展开。如果你直接对Softmax用均方误差,梯度公式不会这么干净,训练效果也差一些。
2.3 数值稳定的Softmax写法与批处理实现
直接写np.exp(z2)在z2里有较大数值时容易溢出。比如z2是100,exp(100)已经非常巨大,再除以总和就可能得到nan。解决办法很简单:每个样本的logits减去该样本的最大值再算Exp。因为对Softmax来说同时加减一个常数结果不变,这个技巧能让最大指数永远不超过1。
另一个容易踩的坑是keepdims。np.max(z2, axis=1, keepdims=True)得到的形状是[batch, 1],这样减到z2上时广播才能自动对齐。如果漏掉keepdims,得到的是[batch],广播规则会把[batch]当成[batch?]去匹配,很多情况下会产生意料之外的错误。
完整的前向代码应该返回两部分:预测概率p,以及供反向传播使用的缓存cache:
def forward(X, params): W1, b1, W2, b2 = params["W1"], params["b1"], params["W2"], params["b2"] z1 = X @ W1 + b1 a1 = np.maximum(0, z1) z2 = a1 @ W2 + b2 # 数值稳定 Softmax z2_shifted = z2 - np.max(z2, axis=1, keepdims=True) exp_z2 = np.exp(z2_shifted) p = exp_z2 / np.sum(exp_z2, axis=1, keepdims=True) cache = {"z1": z1, "a1": a1, "z2": z2} return p, cache如果你批大小是batch,那么$X$的形状是[batch, 784],每一步运算里的@都是矩阵乘法,不再需要写for循环,这就是向量化带给我们的便利。
3. 反向传播:从损失函数逆着链式法则把梯度一步步算出来
3.1 一个大前提:Softmax加交叉熵的梯度恰好是 p - y_true
反向传播的起点是损失函数对输出层线性分数z2的梯度。如果不做化简直接求,要分两种情况讨论Softmax分母对每个分数的影响,非常容易错。但如果你把交叉熵损失和Softmax一起求导,神奇的事情就发生了。
设第i个样本的真实标签One-hot向量为$y_i$,预测分布为$p_i$。损失是$L_i=-\sum_k y_{i,k}\log p_{i,k}$。对每个logit $z_{i,j}$求导,经过链式展开后,等于$p_{i,j}-y_{i,j}$。整个过程的核心就在于:
- 当$j$等于真实类别时,$\frac{\partial \log p_j}{\partial z_j}=1-p_j$,再乘上$y_j=1$的权重,得到$p_j-1$;
- 当$j$不等于真实类别时,$\frac{\partial \log p_j}{\partial z_j}=-p_j$,乘上权重0,但Softmax分母里仍然有$z_j$,所以最终贡献是$p_j-0$。
合起来就是$p-y$。这个结论太重要了,我手推过不止一遍,每次都会感慨它简化得干净。在代码里就一行:
dz2 = p - y_onehot如果p是[batch, 10],y_onehot也是[batch, 10],那么dz2的每个元素含义就是“预测概率和真实标签的差”。误差越大,这个差值越大,反向传回去的梯度也就越大。
3.2 逐层求导:从输出层到隐藏层的梯度传递
得到dz2之后,后面就是按链式法则一层层走下去。先算输出层参数W2的梯度。前向是z2 = a1 @ W2 + b2,所以:
dW2 = a1.T @ dz2 / m db2 = np.sum(dz2, axis=0) / m这里的m是批大小。之所以要除以m,是因为我在损失函数里对批次取了平均。如果你定义损失时没有除以m,那这里就不能除,两处必须保持一致。我见过不少人模型不收敛,最后发现是损失和梯度在是否除以m上不统一。
接着要把梯度传回隐藏层。因为前向是a1 @ W2,反向就需要乘W2.T:
da1 = dz2 @ W2.T dz1 = da1 * (z1 > 0)da1是损失对隐藏层激活值a1的梯度,接下来乘ReLU的导数就是dz1。ReLU的导数在这里就是(z1 > 0),一个布尔矩阵转成浮点后参与乘法。如果z1的元素小于等于0,梯度就是0,这也解释了为什么ReLU会导致一部分“死神经元”。
最后是输入层参数W1和b1:
dW1 = X.T @ dz1 / m db1 = np.sum(dz1, axis=0) / m这里的X.T和dz1 @ W2.T其实遵循同一个规则:反向传播时,梯度流经的权重矩阵要转置。
完整的反向传播函数可以放在一块:
def backward(X, y_true, p, cache, params): m = X.shape[0] W1, W2 = params["W1"], params["W2"] z1, a1 = cache["z1"], cache["a1"] # One-hot 标签 y_onehot = np.zeros_like(p) y_onehot[np.arange(m), y_true] = 1 dz2 = p - y_onehot dW2 = a1.T @ dz2 / m db2 = np.sum(dz2, axis=0) / m da1 = dz2 @ W2.T dz1 = da1 * (z1 > 0) dW1 = X.T @ dz1 / m db1 = np.sum(dz1, axis=0) / m return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}3.3 维数检查:所有梯度的shape必须和参数shape一致
写完反向传播之后,第一件事不是训练,而是检查维度。这是我在手写NumPy过程中总结出的铁律。维度错位不一定会报错,因为NumPy的广播机制会悄悄帮你把错误形状“补”成一个能跑的形状,但结果是完全错的梯度。
我写了一个固定的检查流程:
grads = backward(X_batch, y_batch, p, cache, params) for key in params: assert params[key].shape == grads[key].shape, \ f"Shape mismatch: {key}, {params[key].shape} vs {grads[key].shape}"这个小断言能拦截住大概率问题。如果前向传播时把W的转置方向搞错,反向传播的梯度形状一定会暴露出来。按我们前面的约定,梯度形状应该正好是:dW1 [784, 64],db1 [64],dW2 [64, 10],db2 [10]。
你可能会问:为什么梯度形状必须和参数形状一致?因为马上要做参数更新,W1 -= learning_rate * dW1,如果两个形状不一致,要么报错,要么静默地更新出错误位置,这一点都不可接受。
4. 梯度下降更新与完整的训练循环:让参数真正动起来
4.1 为什么更新是“减去学习率乘梯度”
反向传播算出的梯度指向损失函数上升最快的方向,我们要让损失下降,就必须朝梯度的反方向更新参数。所以参数更新的公式是:
theta = theta - learning_rate * gradient很多人在手写的时候容易手滑写成+=,然后发现损失越训练越高。我之前也因为这个浪费过一整晚,后来只要发现训练损失单调上升,第一反应就去查参数更新代码的符号。
学习率也很关键。lr=0.1在MNIST搭配批大小64时,对这个小网络是合理起点。如果学习率太大,参数更新一步跨太远,损失会震荡甚至发散;如果太小,训练速度肉眼可见地变慢,损失曲线一直很平。
4.2 小批量训练、打乱数据和学习率选择
我把MNIST的训练过程改成小批量方式。以前有些教程会一次性把全部数据丢进去,这样每轮梯度计算慢,而且容易收敛到平坦的极值点。小批量训练的好处是更稳、更快,还附带一定正则化效果。
每次迭代开始前打乱整个训练集,防止模型学到数据顺序里的偶然规律。这是很多人会漏的细节。如果让每个epoch里样本顺序都一样,模型可能记住样本的排列位置,验证集准确率会有波动。
批大小选64的理由很简单:它在计算效率和梯度噪声之间比较平衡。如果你觉得训练慢,可以试着调大;如果你觉得收敛不稳,调小。反向传播的代码不用改,X多了就多了,矩阵乘法天然支持。
4.3 一段可直接跑的完整训练代码
这里我把训练循环整合起来。假设你已经读好了X_train、y_train、X_valid、y_valid,并且把像素值除以了255:
def train( X_train, y_train, X_valid, y_valid, epochs=10, batch_size=64, learning_rate=0.1 ): params = init_params() for epoch in range(epochs): # 每轮打乱数据 perm = np.random.permutation(len(X_train)) X_shuffled = X_train[perm] y_shuffled = y_train[perm] total_loss = 0 for i in range(0, len(X_train), batch_size): X_batch = X_shuffled[i:i + batch_size] y_batch = y_shuffled[i:i + batch_size] p, cache = forward(X_batch, params) grads = backward(X_batch, y_batch, p, cache, params) # 参数更新 for key in params: params[key] -= learning_rate * grads[key] # 累计损失 loss = -np.log(p[np.arange(X_batch.shape[0]), y_batch]).mean() total_loss += loss * X_batch.shape[0] train_loss = total_loss / len(X_train) # 验证集准确率 val_p, _ = forward(X_valid, params) val_pred = np.argmax(val_p, axis=1) val_acc = np.mean(val_pred == y_valid) print(f"epoch {epoch + 1}, loss {train_loss:.4f}, valid acc {val_acc:.4f}") return params这段代码在我的机器上跑10个epoch,验证集准确率能到95%以上。如果你的结果差很多,优先检查这几项:数据有没有归一化、初始化和学习率是不是合适、反向传播梯度有没有除m。
5. 数值梯度校验:用最笨的方法验证反向传播没有写错
5.1 有限差分法的原理
即使反向传播代码写完了,你还是不能完全确定它是对的。这时候最可靠的验证方法不是盯着代码看,而是用数值梯度把解析梯度“测量”出来。原理很简单:导数的定义是极限f'(x) ≈ (f(x+eps) - f(x-eps)) / (2*eps),也叫中心差分。只要把损失函数当成普通函数,把参数当成自变量,就能绕过反向传播公式直接算梯度。
中心差分比单侧差分(f(x+eps)-f(x))/eps更准确,因为它把二阶误差项基本抵消了,在相同eps下精度高一个数量级。对精度要求高时,这是一个很实用的技巧。
5.2 用二维示例对比解析梯度和数值梯度
理论上你可以在完整MNIST网络上做数值梯度,但那会非常慢。因为每个参数需要两次前向传播,而MNIST网络有大约5万个参数。更好的做法是临时构造一个极小的网络:输入2维、隐藏3维、输出2维、随机取少量数据,在这个小模型上跑数值梯度。
数值梯度的实现大概长这样:
def numerical_grad(loss_fn, params, eps=1e-5): grads = {} for key in params: theta = params[key].copy() grad = np.zeros_like(theta) it = np.nditer(theta, flags=["multi_index"], op_flags=["readwrite"]) while not it.finished: idx = it.multi_index old_val = theta[idx] theta[idx] = old_val + eps p_plus, _ = forward(X_small, theta) # 注意这里要替换参数 loss_plus = compute_loss(p_plus, y_small) theta[idx] = old_val - eps p_minus, _ = forward(X_small, theta) loss_minus = compute_loss(p_minus, y_small) theta[idx] = old_val grad[idx] = (loss_plus - loss_minus) / (2 * eps) it.iternext() grads[key] = grad return grads代码里要注意每次都要把原始参数赋值回去。之后把数值梯度和反向传播的解析梯度做比较,用相对误差衡量:
rel_err = np.abs(grad_num - grad_analytical) / (np.abs(grad_num) + np.abs(grad_analytical) + 1e-8)如果所有元素的相对误差都在1e-7以内,反向传播基本可以认为是对的。如果只到1e-4左右,可能是eps太大,或者某些公式细节有偏差。
5.3 定位哪一层梯度出问题的小技巧
如果整体对比发现误差很大,不要慌。一个直接的定位方法是一层一层比较。比如只比较dW1,如果dW1没问题而dW2有问题,说明问题出在输出层附近;如果dW2对但dW1错,说明问题出在梯度的向后传播链路上,比如ReLU的导数用错,或者W2.T的方向写反。
另一个常见问题是损失函数和梯度不匹配。比如你改了损失函数忘记同步改梯度,数值梯度会马上给出“解析梯度完全不对”的信号。说实话,数值梯度虽然慢,但它是我手写神经网络时最信任的调试工具之一。只要你写的网络尺寸不大,跑一次也就几秒,值得多试。
6. 写在最后:这个系列踩坑后最想告诉你的几件事
6.1 最隐蔽的错误:+=和-=只差一个字符,结果天差地别
我把这个单独拎出来说,是因为它真的能浪费你一晚上的时间。看起来只是个符号问题,但手写代码时精神一分散就容易写错。建议在参数更新处写注释:# 梯度方向是上升,更新要反着走,或者干脆形成条件反射,只要是更新就下意识写-=。
6.2 NumPy广播的维度陷阱比数学公式更容易让人崩溃
数学推导我能很快完成,但代码里最常报错的地方往往是shape不匹配。比如对一个形状[batch]的数组使用keepdims=False,广播到[batch, 10]时就会得到错误结果。我建议每写完一段NumPy运算,先打印a.shape验证,尤其在从单样本调试切到批处理时,很有可能忘记补一个batch维度。
6.3 数据归一化是训练稳定的第一前提
MNIST原始像素范围是0到255,如果不除以255,784维的输入乘上权重后数值会很大,Softmax很容易饱和,梯度也容易爆炸。现在深度学习框架里通常封装好了归一化层,但在手写实现里你得自己记得做。这类基础步骤虽然不起眼,但决定了整个训练能不能正常进行。
把前向传播和反向传播用纯NumPy完整写一遍之后,再回头看任何框架的文档,你都会觉得亲切很多。这个系列写到这里,一个可训练的MNIST分类器已经全部搭完。下一篇我打算聊聊训练里的学习率调节和过拟合现象,如果你有更感兴趣的切入点,也欢迎告诉我。