共计 3189 个字符,预计需要花费 8 分钟才能阅读完成。
为什么手动实现神经网络?
很多同学第一次接触深度学习时,可能直接调用了 TensorFlow 或 PyTorch 的现成接口。但手动实现一次神经网络的反向传播过程,能帮你真正理解以下核心问题:

- 权重矩阵如何通过梯度逐步调整
- 激活函数对梯度流动的实际影响
- 为什么深层网络会出现梯度消失 / 爆炸
这就像学会用计算器之前,先要理解加减乘除的原理。
新手常踩的 3 个坑
在实现反向传播时,90% 的初学者会遇到这些问题:
- 矩阵维度不匹配 :
- 误将权重矩阵写成 (input_dim, hidden_dim) 而不是 (hidden_dim, input_dim)
-
前向传播时忘记转置:$h = W^T x + b$ → 实际代码需写
W.T @ x -
漏掉激活函数导数 :
- 计算隐藏层梯度时忘记乘 ReLU 的导数:$\frac{\partial L}{\partial z} = \frac{\partial L}{\partial a} \odot f'(z)$
-
常见错误是只计算了 $\frac{\partial L}{\partial a}$
-
学习率设置不当 :
- 使用固定学习率导致震荡(损失值上下跳动)
- 未根据梯度幅值动态调整步长
数学推导:链式法则实战
以单隐藏层网络为例,输入 $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)
六大避坑技巧
- 权重初始化 :
- 使用 ReLU 时推荐 He 初始化:$W \sim N(0, \sqrt{2/n_{in}})$
-
太大→梯度爆炸,太小→所有神经元输出为 0(死亡 ReLU)
-
梯度裁剪 :
- 当梯度范数超过 1 时进行裁剪:
grad = np.clip(grad, -1, 1) -
尤其对 RNN/LSTM 等网络有效
-
数值梯度检验 :
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
-
学习率衰减 :
- 每 10 个 epoch 减半:
lr = initial_lr * (0.5 ** (epoch//10)) -
或使用余弦退火:
lr = 0.5 * lr_max * (1 + np.cos(epoch/total_epochs*np.pi)) -
Batch Size 选择 :
- CPU 训练建议 32-128
-
GPU 可用到 1024(需同步增加学习率)
-
类型注解规范 :
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/
