从零实现两个神经元、两个隐藏层的全连接神经网络:手写反向传播算法实战

1次阅读
没有评论

共计 3241 个字符,预计需要花费 9 分钟才能阅读完成。

image.webp

为什么反向传播让初学者头疼?

刚开始学神经网络时,我总被反向传播(Backpropagation)卡住。明明前向传播看着很直观——数据一层层往前算,但到了反向传播就晕了:为什么要用链式法则?梯度到底怎么传递的?后来发现关键痛点有两个:

从零实现两个神经元、两个隐藏层的全连接神经网络:手写反向传播算法实战

  • 链式法则的物理意义不明确:教材上的∂L/∂W=∂L/∂z·∂z/∂W 这种公式,初学者很难对应到实际代码中的矩阵运算
  • 维度匹配问题:特别是当网络有多个隐藏层时,容易搞错权重矩阵和梯度矩阵的维度关系

数学推导:拆解两层网络的反向传播

假设我们的网络结构如下(输入层 2 节点→隐藏层 1(2 神经元)→隐藏层 2(2 神经元)→输出层 1 节点),用 ReLU 激活函数:

\begin{aligned}
z_1 &= W_1 X + b_1 \\
a_1 &= \text{ReLU}(z_1) \\
z_2 &= W_2 a_1 + b_2 \\
a_2 &= \text{ReLU}(z_2) \\
z_3 &= W_3 a_2 + b_3 \\
\hat{y} &= \sigma(z_3) \quad \text{(输出层用 Sigmoid)}
\end{aligned}

损失函数用交叉熵损失 $L = -[y\ln\hat{y} + (1-y)\ln(1-\hat{y})]$,推导 $W_2$ 的梯度时(其他权重类似):

\frac{\partial L}{\partial W_2} = \underbrace{\frac{\partial L}{\partial z_3}}_{δ_3} \frac{\partial z_3}{\partial a_2} \underbrace{\frac{\partial a_2}{\partial z_2}}_{δ_2} \frac{\partial z_2}{\partial W_2}

其中关键点:

  1. $δ_3 = \hat{y} – y$(输出层梯度)
  2. $δ_2 = (W_3^T δ_3) \odot \text{ReLU}'(z_2)$(隐藏层 2 的局部梯度)
  3. 最终 $\partial L/\partial W_2 = a_1 δ_2^T$(注意矩阵乘法的顺序和转置)

Python 实现:NumPy 手写全过程

import numpy as np
from typing import Tuple, List

def relu(x: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
    """ReLU 激活函数及导数的缓存"""
    mask = (x > 0).astype(float)
    return x * mask, mask

def sigmoid(x: np.ndarray) -> np.ndarray:
    return 1 / (1 + np.exp(-x))

class TwoLayerNN:
    def __init__(self, input_size: int, hidden_size: int):
        # 初始化权重(He 初始化)self.W1 = np.random.randn(hidden_size, input_size) * np.sqrt(2./input_size)
        self.b1 = np.zeros((hidden_size, 1))
        self.W2 = np.random.randn(hidden_size, hidden_size) * np.sqrt(2./hidden_size)
        self.b2 = np.zeros((hidden_size, 1))
        self.W3 = np.random.randn(1, hidden_size) * np.sqrt(2./hidden_size)
        self.b3 = np.zeros((1, 1))

    def forward(self, X: np.ndarray) -> Tuple[List[np.ndarray], List[np.ndarray]]:
        """前向传播,返回各层激活值和未激活值"""
        self.z1 = self.W1 @ X + self.b1
        self.a1, self.da1 = relu(self.z1)

        self.z2 = self.W2 @ self.a1 + self.b2
        self.a2, self.da2 = relu(self.z2)

        self.z3 = self.W3 @ self.a2 + self.b3
        self.y_hat = sigmoid(self.z3)

        return [self.a1, self.a2, self.y_hat], [self.z1, self.z2, self.z3]

    def backward(self, X: np.ndarray, y: np.ndarray) -> dict:
        """反向传播计算梯度"""
        m = X.shape[1]  # 样本数

        # 输出层梯度
        dL_dz3 = self.y_hat - y
        dW3 = dL_dz3 @ self.a2.T / m
        db3 = np.sum(dL_dz3, axis=1, keepdims=True) / m

        # 第二隐藏层梯度
        dL_dz2 = (self.W3.T @ dL_dz3) * self.da2
        dW2 = dL_dz2 @ self.a1.T / m
        db2 = np.sum(dL_dz2, axis=1, keepdims=True) / m

        # 第一隐藏层梯度
        dL_dz1 = (self.W2.T @ dL_dz2) * self.da1
        dW1 = dL_dz1 @ X.T / m
        db1 = np.sum(dL_dz1, axis=1, keepdims=True) / m

        return {'W1': dW1, 'b1': db1, 'W2': dW2, 'b2': db2, 'W3': dW3, 'b3': db3}

关键实现细节:

  1. 所有矩阵运算用 @ 操作符,避免 np.dot 的歧义
  2. 梯度计算时除以样本数 m,得到平均梯度
  3. ReLU 的导数用 mask 缓存,避免重复计算

新手必踩的 3 个大坑

1. 梯度爆炸 / 消失

  • 现象:训练时 loss 突然变成 NaN
  • 解决
  • 使用合适的权重初始化(如代码中的 He 初始化)
  • 添加梯度裁剪grad = np.clip(grad, -1, 1)

2. 学习率设置不当

  • 现象:loss 震荡不下降或下降极慢
  • 测试方法:先用小学习率(如 0.001)试跑,观察 loss 曲线

3. 维度不匹配

  • 典型错误
    # 错误写法:dW = X.T @ delta  # 当 X 是 (n_feature, n_sample) 时会出错
    # 正确写法:dW = delta @ X.T
  • 检查技巧 :打印每步变量的shape 属性

验证:数值梯度检验

def numerical_gradient(f, x: np.ndarray, eps=1e-4) -> np.ndarray:
    """计算数值梯度用于验证"""
    grad = np.zeros_like(x)
    for i in range(x.shape[0]):
        for j in range(x.shape[1]):
            tmp = x[i,j]

            x[i,j] = tmp + eps
            f_plus = f()

            x[i,j] = tmp - eps
            f_minus = f()

            grad[i,j] = (f_plus - f_minus) / (2*eps)
            x[i,j] = tmp
    return grad

# 使用示例:W = model.W1.copy()
def loss_func():
    model.W1 = W
    y_hat, _ = model.forward(X)
    return -np.mean(y*np.log(y_hat) + (1-y)*np.log(1-y_hat))

num_grad = numerical_gradient(loss_func, W)
analytic_grad = model.backward(X, y)['W1']
print("梯度差异:", np.linalg.norm(num_grad - analytic_grad))

当差异小于 1e- 7 时,说明反向传播实现正确。

下一步挑战

尝试以下扩展练习:

  1. 增加第三个隐藏层,观察梯度传递的变化
  2. 将 ReLU 替换为 LeakyReLU 或 Sigmoid,注意梯度表达式的修改
  3. 添加 L2 正则化项,需要在 loss 和梯度计算中增加权重衰减项

通过这个最小化的实现,你应该能真正理解反向传播如何通过链式法则将误差从输出层传递回底层。下次看到深度学习框架的 model.backward() 时,就知道背后发生了什么魔法!

正文完
 0
评论(没有评论)