从零实现多层神经网络传导:梯度下降算法详解与避坑指南

1次阅读
没有评论

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

image.webp

为什么手动实现神经网络?

很多同学第一次接触深度学习时,可能直接调用了 TensorFlow 或 PyTorch 的现成接口。但手动实现一次神经网络的反向传播过程,能帮你真正理解以下核心问题:

从零实现多层神经网络传导:梯度下降算法详解与避坑指南

  • 权重矩阵如何通过梯度逐步调整
  • 激活函数对梯度流动的实际影响
  • 为什么深层网络会出现梯度消失 / 爆炸

这就像学会用计算器之前,先要理解加减乘除的原理。

新手常踩的 3 个坑

在实现反向传播时,90% 的初学者会遇到这些问题:

  1. 矩阵维度不匹配
  2. 误将权重矩阵写成 (input_dim, hidden_dim) 而不是 (hidden_dim, input_dim)
  3. 前向传播时忘记转置:$h = W^T x + b$ → 实际代码需写 W.T @ x

  4. 漏掉激活函数导数

  5. 计算隐藏层梯度时忘记乘 ReLU 的导数:$\frac{\partial L}{\partial z} = \frac{\partial L}{\partial a} \odot f'(z)$
  6. 常见错误是只计算了 $\frac{\partial L}{\partial a}$

  7. 学习率设置不当

  8. 使用固定学习率导致震荡(损失值上下跳动)
  9. 未根据梯度幅值动态调整步长

数学推导:链式法则实战

以单隐藏层网络为例,输入 $X$(shape: n_samples×input_dim)经过以下计算:

[
\begin{aligned}
Z_1 &= X W_1^T + b_1 \quad &(\text{ 隐藏层线性变换}) \
A_1 &= \text{ReLU}(Z_1) \quad &(\text{ 激活函数}) \
Z_2 &= A_1 W_2^T + b_2 \quad &(\text{ 输出层线性变换}) \
\hat{Y} &= \text{softmax}(Z_2) \quad &(\text{ 分类概率})
\end{aligned}
]

反向传播时,需要计算损失 $L$ 对各个参数的梯度。以输出层权重 $W_2$ 为例:

[
\frac{\partial L}{\partial W_2} = \frac{\partial L}{\partial Z_2} \cdot \frac{\partial Z_2}{\partial W_2} = (\hat{Y} – Y) \cdot A_1
]

这里的维度变化是:

$\hat{Y}-Y$ → (n_samples×output_dim)
$A_1$ → (n_samples×hidden_dim)
最终梯度 $\frac{\partial L}{\partial W_2}$ → (output_dim×hidden_dim)

梯度下降的三种姿势

方法 每次更新数据量 内存占用 收敛速度
批量梯度下降 (BGD) 全部样本 稳定但慢
随机梯度下降 (SGD) 1 个样本 抖动明显
Mini-Batch 32-256 样本 中等 平衡

推荐初学者使用 Mini-Batch,既能利用向量化加速,又避免内存爆炸。

完整代码实现

# Python 3.8+ 需要 numpy 库
import numpy as np

class NeuralNetwork:
    def __init__(self, input_dim, hidden_dim, output_dim):
        # He 初始化更适合 ReLU
        self.W1 = np.random.randn(hidden_dim, input_dim) * np.sqrt(2/input_dim)
        self.b1 = np.zeros(hidden_dim)
        self.W2 = np.random.randn(output_dim, hidden_dim) * np.sqrt(2/hidden_dim)
        self.b2 = np.zeros(output_dim)

    def forward(self, X):
        """ 前向传播
        输入: X (n_samples×input_dim)
        输出: 概率分布 (n_samples×output_dim)
        """
        self.Z1 = X @ self.W1.T + self.b1  # (n_samples×hidden_dim)
        self.A1 = np.maximum(0, self.Z1)   # ReLU 激活
        self.Z2 = self.A1 @ self.W2.T + self.b2
        return softmax(self.Z2)

    def backward(self, X, y_true, lr=0.01):
        """ 反向传播
        y_true: one-hot 编码标签 (n_samples×output_dim)
        """
        n_samples = X.shape[0]
        y_pred = self.forward(X)

        # 输出层梯度
        dZ2 = y_pred - y_true  # (n_samples×output_dim)
        dW2 = dZ2.T @ self.A1 / n_samples  # (output_dim×hidden_dim)
        db2 = np.mean(dZ2, axis=0)

        # 隐藏层梯度
        dA1 = dZ2 @ self.W2  # (n_samples×hidden_dim)
        dZ1 = dA1 * (self.A1 > 0)  # ReLU 导数
        dW1 = dZ1.T @ X / n_samples
        db1 = np.mean(dZ1, axis=0)

        # 梯度裁剪(防止爆炸)for grad in [dW1, db1, dW2, db2]:
            np.clip(grad, -1, 1, out=grad)

        # 更新参数
        self.W2 -= lr * dW2
        self.b2 -= lr * db2
        self.W1 -= lr * dW1
        self.b1 -= lr * db1

def softmax(x):
    exps = np.exp(x - np.max(x, axis=1, keepdims=True))
    return exps / np.sum(exps, axis=1, keepdims=True)

六大避坑技巧

  1. 权重初始化
  2. 使用 ReLU 时推荐 He 初始化:$W \sim N(0, \sqrt{2/n_{in}})$
  3. 太大→梯度爆炸,太小→所有神经元输出为 0(死亡 ReLU)

  4. 梯度裁剪

  5. 当梯度范数超过 1 时进行裁剪:grad = np.clip(grad, -1, 1)
  6. 尤其对 RNN/LSTM 等网络有效

  7. 数值梯度检验

    def check_gradient():
        # 对 W1 的每个参数加入微小扰动
        eps = 1e-5
        numeric_grad = np.zeros_like(W1)
        for i in range(W1.shape[0]):
            for j in range(W1.shape[1]):
                W1[i,j] += eps
                loss_plus = compute_loss()
                W1[i,j] -= 2*eps
                loss_minus = compute_loss()
                numeric_grad[i,j] = (loss_plus - loss_minus)/(2*eps)
        # 与反向传播结果对比
        print(np.mean(np.abs(numeric_grad - dW1)))

    误差应小于 1e-7

  8. 学习率衰减

  9. 每 10 个 epoch 减半:lr = initial_lr * (0.5 ** (epoch//10))
  10. 或使用余弦退火:lr = 0.5 * lr_max * (1 + np.cos(epoch/total_epochs*np.pi))

  11. Batch Size 选择

  12. CPU 训练建议 32-128
  13. GPU 可用到 1024(需同步增加学习率)

  14. 类型注解规范

    def forward(self, X: np.ndarray) -> np.ndarray:
        """
        参数:
            X: 输入数据,形状 (n_samples, input_dim)
        返回:
            预测概率,形状 (n_samples, output_dim)
        """

思考题

如果输出层改用线性激活函数(例如回归任务):
1. 损失函数应该用什么?
2. $\frac{\partial L}{\partial Z_2}$ 该如何计算?
3. 初始化方法需要调整吗?

推荐扩展阅读:
– [原始论文] Xavier 初始化:http://proceedings.mlr.press/v9/glorot10a.html
– 反向传播可视化:https://cs231n.github.io/optimization-2/

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