昨天调一个三层的全连接网络,loss死活不降。打印梯度发现第一层的权重全是零——反向传播根本没传过去。同事凑过来看了一眼:“你激活函数梯度写错了吧?”一查代码,果然在tanh求导的地方少了个平方。这种低级错误让我想起刚入门时,反向传播的链式法则绕得人头晕。今天咱们就掰开揉碎讲清楚,神经网络到底是怎么“学习”的。

从实际问题切入:梯度去哪了?

假设你在训练一个简单的分类网络,结构是输入层(784) -> 隐藏层(128) -> 输出层(10)。前向推理代码大概长这样:

def forward(x):
    z1 = x @ w1 + b1  # 第一层线性变换
    a1 = np.tanh(z1)   # 激活函数
    z2 = a1 @ w2 + b2
    output = softmax(z2)
    return output

前向传播容易理解,就是数据一层层流过去。问题出在反向传播:模型输出和真实标签的误差,怎么反过来告诉每一层的权重该往哪个方向调整?

反向传播:链式法则的工程实现

反向传播的核心是链式求导。但工程实现时,我们通常从后往前计算“局部梯度”。以两层网络为例,损失函数L对w1的梯度要经过三层传递:

∂L/∂w1 = ∂L/∂z2 · ∂z2/∂a1 · ∂a1/∂z1 · ∂z1/∂w1

实际编程时,我们不会真的展开这个式子,而是用计算图的思想逐层回传。下面这个反向传播的实现很典型:

def backward(x, y, output):
    # 输出层梯度
    dz2 = output - y  # softmax交叉熵的梯度就是这个简洁形式,记住能省事
    
    # 第二层权重梯度
    dw2 = a1.T @ dz2  # 这里形状要对齐,转置经常忘
    db2 = np.sum(dz2, axis=0)
    
    # 往第一层回传
    da1 = dz2 @ w2.T  # 注意这里w2要转置,维度匹配容易出错
    dz1 = da1 * (1 - a1**2)  # tanh的导数!我当初就是这里少写了平方
    
    # 第一层权重梯度
    dw1 = x.T @ dz1
    db1 = np.sum(dz1, axis=0)
    
    return dw1, db1, dw2, db2

注意看第11行,tanh的导数是1 - tanh(x)^2。如果你写成1 - tanh(x),梯度值会小很多,前面层的权重几乎更新不动。这就是开头说的bug来源。

激活函数选型:别只看准确率

新手常纠结用什么激活函数。ReLU确实流行,但死亡ReLU问题在嵌入式设备上很头疼——某些神经元一旦输出全零就再也激活不了。我的经验是:

  • 中间层用LeakyReLU,给负值留个0.01的斜率,避免死亡
  • 二分类输出层用sigmoid,多分类用softmax,回归任务用线性层
  • tanh在RNN里还行,但深度网络容易梯度消失
# 激活函数实现别写太复杂
def leaky_relu(x, alpha=0.01):
    return np.where(x > 0, x, x * alpha)  # 向量化操作,比for循环快几十倍

# 反向传播时梯度要对应
def leaky_relu_grad(x, alpha=0.01):
    dx = np.ones_like(x)
    dx[x < 0] = alpha  # 小于0的位置梯度为alpha
    return dx

初始化权重:不是小事

权重初始化不对,网络可能一开始就“死”了。比如全用零初始化,所有神经元输出相同,梯度也相同,相当于一个神经元。常用的Xavier初始化实现起来很简单:

def init_weights(input_size, output_size):
    limit = np.sqrt(6 / (input_size + output_size))
    return np.random.uniform(-limit, limit, (input_size, output_size))

但要注意,Xavier假设激活函数是线性的,对ReLU系激活函数效果打折扣。这时用He初始化(方差为2/n)更合适。

调试技巧:梯度检查

当你自己实现反向传播时,一定要做梯度检查。用数值梯度验证解析梯度:

def grad_check(layer_func, x, w, epsilon=1e-7):
    grad_analytic = backward(x, w)  # 你的反向传播结果
    grad_numeric = np.zeros_like(w)
    
    for i in range(w.size):
        w_plus = w.copy()
        w_minus = w.copy()
        w_plus.flat[i] += epsilon
        w_minus.flat[i] -= epsilon
        
        loss_plus = layer_func(x, w_plus)
        loss_minus = layer_func(x, w_minus)
        grad_numeric.flat[i] = (loss_plus - loss_minus) / (2 * epsilon)
    
    diff = np.linalg.norm(grad_analytic - grad_numeric) / np.linalg.norm(grad_analytic + grad_numeric)
    return diff < 1e-7  # 小于这个阈值基本就对了

这个操作计算量大,训练时别用,但验证新网络结构时能救命。

个人经验:别重复造轮子,但要懂轮子

现在深度学习框架很成熟,TensorFlow/PyTorch一行代码就搞定反向传播。但我建议初学者至少手写一次全连接网络的反向传播,包括矩阵维度变换、激活函数求导、损失函数求导。这个过程能让你真正理解:

  1. 梯度消失/爆炸是怎么发生的(试试点乘操作连乘十次)
  2. 批量训练时梯度怎么平均(注意axis参数)
  3. dropout在反向传播时要做什么(mask要记住)

实际项目中,我通常用框架搭原型,但遇到性能瓶颈或部署到嵌入式设备时,经常要拆开网络手动优化。这时候对反向传播的理解就值钱了。

最后给个实用建议:把常用层的梯度公式写成小抄贴在显示器边上,比如卷积层的im2col梯度、LSTM的梯度流。调试时对照着看,能省下大量瞎猜的时间。神经网络训练不收敛时,先检查梯度,再调学习率,最后怀疑数据——这个顺序能帮你少走弯路。

更多推荐