目录
  1. 1. 原理
  2. 2. 1. 引言:为什么BP算法如此重要
  3. 3. 2. 数学符号与网络结构定义
    1. 3.1. 2.1 符号约定
    2. 3.2. 2.2 损失函数
  4. 4. 3. 前向传播(Forward Propagation)
    1. 4.1. 3.1 逐层计算
    2. 4.2. 3.2 伪代码
    3. 4.3. 3.3 三层网络具体示例
  5. 5. 4. 反向传播推导(Backward Propagation)
    1. 5.1. 4.1 输出层的误差信号 δ^[L]
    2. 5.2. 4.2 隐藏层的误差信号 δ^[l](l < L)
    3. 5.3. 4.3 权重梯度 ∂E/∂W^[l]
    4. 5.4. 4.4 偏置梯度 ∂E/∂b^[l]
    5. 5.5. 4.5 完整的反向传播算法伪代码
  6. 6. 5. 矩阵/向量形式的高效计算
    1. 6.1. 5.1 批量前向传播
    2. 6.2. 5.2 批量反向传播
  7. 7. 6. 激活函数及其导数分析
    1. 7.1. 6.1 Sigmoid
    2. 7.2. 6.2 Tanh(双曲正切)
    3. 7.3. 6.3 ReLU(Rectified Linear Unit)
    4. 7.4. 6.4 LeakyReLU
    5. 7.5. 6.5 ELU(Exponential Linear Unit)
    6. 7.6. 6.6 Swish(SiLU)
    7. 7.7. 6.7 激活函数对比表
  8. 8. 7. 梯度消失与梯度爆炸
    1. 8.1. 7.1 梯度消失(Vanishing Gradient)的数学根源
    2. 8.2. 7.2 梯度爆炸(Exploding Gradient)
    3. 8.3. 7.3 梯度裁剪(Gradient Clipping)
  9. 9. 8. 数值梯度检查(Numerical Gradient Checking)
    1. 9.1. 8.1 双边有限差分法
    2. 9.2. 8.2 实现代码
    3. 9.3. 8.3 梯度比较
  10. 10. 9. 优化器:BP梯度如何驱动参数更新
    1. 10.1. 9.1 标准 SGD(Stochastic Gradient Descent)
    2. 10.2. 9.2 Momentum(带动量的 SGD)
    3. 10.3. 9.3 Nesterov Accelerated Gradient (NAG)
    4. 10.4. 9.4 AdaGrad(Adaptive Gradient)
    5. 10.5. 9.5 RMSprop
    6. 10.6. 9.6 Adam(Adaptive Moment Estimation)
    7. 10.7. 9.7 优化器对比
  11. 11. 10. 批归一化(Batch Normalization)
    1. 11.1. 10.1 动机
    2. 11.2. 10.2 前向传播
    3. 11.3. 10.3 反向传播推导
    4. 11.4. 10.4 训练 vs 推理
  12. 12. 11. 权重初始化策略
    1. 12.1. 11.1 Xavier(Glorot)初始化
    2. 12.2. 11.2 He(Kaiming)初始化
    3. 12.3. 11.3 偏置初始化
  13. 13. 12. 学习率调度(Learning Rate Schedules)
  14. 14. 13. 计算图视角与自动微分
    1. 14.1. 13.1 计算图
    2. 14.2. 13.2 反向模式自动微分
    3. 14.3. 13.3 PyTorch Autograd 示例
    4. 14.4. 13.4 动态图 vs 静态图
  15. 15. 14. NumPy 实现:完整的 BP 训练示例
  16. 16. 15. 训练实用技巧总结
    1. 16.1. 15.1 数据预处理
    2. 16.2. 15.2 梯度相关技巧
    3. 16.3. 15.3 调试 BP 的步进清单
  17. 17. 16. 面试高频问答
【经典算法】神经网络BP算法

误差逆传播算法-最成功的神经网络学习算法

原理

对每个训练样例,BP算法执行以下操作:

先将输入示例提供给输入层神经元,然后逐层将新号前传,直到产生输出层的结果;  
然后计算输出层的误差,再将误差逆向传播至隐藏神经元;
最后根据隐层神经元的误差来对连接权和阈值进行调整。

该迭代过程循环进行,直到达到某些停止条件为止。

1. 引言:为什么BP算法如此重要

反向传播(Backpropagation,BP)算法是训练前馈神经网络最核心的算法。它由 Rumelhart、Hinton 和 Williams 于 1986 年在 Nature 上系统性地提出,但这并非一蹴而就——早在 1960 年代,控制论领域就用到了类似的链式法则思想;1970 年代,Werbos 在博士论文中首次将反向模式自动微分应用于神经网络。

BP的本质是:利用链式法则(Chain Rule),高效地计算损失函数对网络中每一个可学习参数(权重和偏置)的偏导数(梯度)。有了这些梯度,我们就能用梯度下降法(或它的变体)来更新参数,使损失函数逐步减小。

为什么叫”反向传播”?因为梯度的计算方向与信号的前向传播方向相反——从输出层开始,逐层向输入层传播误差信号。

BP算法的核心贡献在于计算效率。对于一个具有 N 层、每层 M 个神经元的网络,如果使用数值微分来计算梯度,每计算一个参数的梯度需要两次前向传播,总复杂度为 O(M^2 * N)。而BP算法利用链式法则和动态规划的思想,共享中间计算结果,将总复杂度降为 O(M * N)(即一次前向传播 + 一次反向传播)。

2. 数学符号与网络结构定义

在深入推导之前,我们先建立严格的符号系统。考虑一个 L 层的全连接神经网络(输入层算作第 0 层,不计入 L):

2.1 符号约定

符号 含义
L 网络层数(不包含输入层)
n_l 第 l 层的神经元数目
W^[l] 第 l-1 层到第 l 层的权重矩阵,维度 (n_l, n_{l-1})
b^[l] 第 l 层的偏置向量,维度 (n_l, 1)
z^[l] 第 l 层的加权输入(激活前),维度 (n_l, 1)
a^[l] 第 l 层的激活输出,维度 (n_l, 1)
a^[0] = x 输入层的激活值,即输入特征
a^[L] = ŷ 网络的最终输出(预测值)
g^l 第 l 层的激活函数
E 损失函数(误差函数)
δ^[l] 第 l 层的误差信号(error signal)
逐元素乘法(Hadamard product)

2.2 损失函数

本文主要使用二次损失函数(均方误差,MSE)作为示例:

$$E = \frac{1}{2} \sum_{j=1}^{n_L} (a_j^{[L]} - y_j)^2 = \frac{1}{2} \|\hat{y} - y\|_2^2$$

对于分类问题,更常用交叉熵损失(Cross-Entropy Loss)。对于二分类(sigmoid输出):

$$E = -[y \log \hat{y} + (1-y) \log(1-\hat{y})]$$

对于多分类(softmax输出):

$$E = -\sum_{j=1}^{n_L} y_j \log \hat{y}_j$$

其中 y_j 是 one-hot 编码的真实标签。

3. 前向传播(Forward Propagation)

前向传播就是将输入信号逐层传递,最终产生预测结果的过程。

3.1 逐层计算

对于第 l 层(l = 1, 2, …, L):

步骤1:计算加权输入
$$z^{[l]} = W^{[l]} \cdot a^{[l-1]} + b^{[l]}$$

写成逐神经元的形式:

$$z_j^{[l]} = \sum_{k=1}^{n_{l-1}} W_{jk}^{[l]} \cdot a_k^{[l-1]} + b_j^{[l]}$$

步骤2:应用激活函数
$$a^{[l]} = g^{[l]}(z^{[l]})$$

3.2 伪代码

def forward_pass(X, parameters):
"""
X: 输入数据,shape (n_0, m) # m 为样本数
parameters: {'W1': ..., 'b1': ..., 'W2': ..., 'b2': ..., ...}
返回: 各层的 z 和 a(缓存在 cache 中供反向传播使用)
"""
cache = {'a0': X}
A = X
for l in range(1, L + 1):
Z = parameters[f'W{l}'] @ A + parameters[f'b{l}']
A = activation(Z, activation_type=f'g{l}')
cache[f'z{l}'] = Z
cache[f'a{l}'] = A
return A, cache # A 即最终的预测值

3.3 三层网络具体示例

以 n_0 = 3(输入特征数),n_1 = 4(隐藏层神经元数),n_2 = 2(输出层神经元数)为例:

隐藏层(l=1):

Z1 = W1 @ X + b1          # shape: (4, 1)
A1 = sigmoid(Z1) # shape: (4, 1)

输出层(l=2):

Z2 = W2 @ A1 + b2          # shape: (2, 1)
A2 = sigmoid(Z2) # shape: (2, 1) 即预测值 ŷ

4. 反向传播推导(Backward Propagation)

这是本文最核心的部分。我们将使用链式法则,从输出层开始,逐层向后推导梯度。

4.1 输出层的误差信号 δ^[L]

定义第 l 层的误差信号为损失函数对该层加权输入的偏导数:

$$\delta^{[l]} = \frac{\partial E}{\partial z^{[l]}}$$

对于输出层(l = L),以 MSE 损失为例:

$$E = \frac{1}{2} \sum_{j} (a_j^{[L]} - y_j)^2$$

首先计算 ∂E/∂a^[L]:

$$\frac{\partial E}{\partial a_j^{[L]}} = a_j^{[L]} - y_j$$

即:

$$\frac{\partial E}{\partial a^{[L]}} = \hat{y} - y$$

现在利用链式法则求 δ^[L] = ∂E/∂z^[L]:

$$\delta_j^{[L]} = \frac{\partial E}{\partial z_j^{[L]}} = \frac{\partial E}{\partial a_j^{[L]}} \cdot \frac{\partial a_j^{[L]}}{\partial z_j^{[L]}} = (a_j^{[L]} - y_j) \cdot g'^{[L]}(z_j^{[L]})$$

写成向量形式:

$$\boxed{\delta^{[L]} = (\hat{y} - y) \odot g'^{[L]}(z^{[L]})}$$

重要:如果输出层使用 sigmoid 激活 + 交叉熵损失,可以证明 δ^[L] 有非常简洁的形式:

$$\delta^{[L]} = \hat{y} - y$$

这就是为什么分类任务中常使用 sigmoid + 交叉熵 或 softmax + 交叉熵的组合——它们能消除激活函数导数项,加速训练。

4.2 隐藏层的误差信号 δ^[l](l < L)

对于隐藏层,误差信号需要从后一层反向传播。核心递归关系:

$$\delta_j^{[l]} = \frac{\partial E}{\partial z_j^{[l]}} = \sum_{k=1}^{n_{l+1}} \frac{\partial E}{\partial z_k^{[l+1]}} \cdot \frac{\partial z_k^{[l+1]}}{\partial z_j^{[l]}}$$

关键在于 ∂z_k^{[l+1]} / ∂z_j^{[l]} 的计算。注意到:

$$z_k^{[l+1]} = \sum_{i=1}^{n_l} W_{ki}^{[l+1]} \cdot a_i^{[l]} + b_k^{[l+1]} = \sum_{i=1}^{n_l} W_{ki}^{[l+1]} \cdot g^{[l]}(z_i^{[l]}) + b_k^{[l+1]}$$

因此:

$$\frac{\partial z_k^{[l+1]}}{\partial z_j^{[l]}} = W_{kj}^{[l+1]} \cdot g'^{[l]}(z_j^{[l]})$$

代入递归式:

$$\delta_j^{[l]} = \sum_{k=1}^{n_{l+1}} \delta_k^{[l+1]} \cdot W_{kj}^{[l+1]} \cdot g'^{[l]}(z_j^{[l]}) = g'^{[l]}(z_j^{[l]}) \cdot \sum_{k=1}^{n_{l+1}} \delta_k^{[l+1]} W_{kj}^{[l+1]}$$

向量化形式:

$$\boxed{\delta^{[l]} = (W^{[l+1]})^T \cdot \delta^{[l+1]} \odot g'^{[l]}(z^{[l]})}$$

其中 (W^{[l+1]})^T 是 W^{[l+1]} 的转置。

4.3 权重梯度 ∂E/∂W^[l]

对于权重矩阵 W^[l] 中的每一个元素 W_{jk}^{[l]}(连接第 l-1 层的第 k 个神经元到第 l 层的第 j 个神经元):

$$\frac{\partial E}{\partial W_{jk}^{[l]}} = \frac{\partial E}{\partial z_j^{[l]}} \cdot \frac{\partial z_j^{[l]}}{\partial W_{jk}^{[l]}} = \delta_j^{[l]} \cdot a_k^{[l-1]}$$

向量化形式(考虑该层所有神经元的权重梯度):

$$\boxed{\frac{\partial E}{\partial W^{[l]}} = \delta^{[l]} \cdot (a^{[l-1]})^T}$$

维度验证:δ^[l] : (n_l, 1),(a^{[l-1]})^T : (1, n_{l-1}) → 结果 (n_l, n_{l-1}),与 W^[l] 的维度一致。

4.4 偏置梯度 ∂E/∂b^[l]

对于偏置 b_j^{[l]}:

$$\frac{\partial E}{\partial b_j^{[l]}} = \frac{\partial E}{\partial z_j^{[l]}} \cdot \frac{\partial z_j^{[l]}}{\partial b_j^{[l]}} = \delta_j^{[l]} \cdot 1 = \delta_j^{[l]}$$

因为 ∂z_j^{[l]}/∂b_j^{[l]} = 1(b_j 只是 z_j 的一个加项)。向量形式:

$$\boxed{\frac{\partial E}{\partial b^{[l]}} = \delta^{[l]}}$$

4.5 完整的反向传播算法伪代码

def backward_pass(y, cache, parameters):
"""
y: 真实标签,shape (n_L, m)
cache: 前向传播中缓存的 z 和 a
parameters: 权重和偏置字典
返回: gradients 字典 {'dW1': ..., 'db1': ..., ...}
"""
m = y.shape[1] # 样本数
grads = {}

# Step 1: 输出层误差信号
dZ = cache[f'a{L}'] - y # δ^[L](假设 MSE + 线性输出,或 sigmoid+CE)

# Step 2: 反向逐层计算
for l in reversed(range(1, L + 1)):
# 计算梯度
grads[f'dW{l}'] = (1/m) * dZ @ cache[f'a{l-1}'].T
grads[f'db{l}'] = (1/m) * np.sum(dZ, axis=1, keepdims=True)

# 计算前一层的误差信号(如果不是第一层)
if l > 1:
dA_prev = parameters[f'W{l}'].T @ dZ
dZ = dA_prev * activation_derivative(cache[f'z{l-1}'])

return grads

5. 矩阵/向量形式的高效计算

在实际实现中(如 NumPy、PyTorch、TensorFlow),所有计算都是批量处理的。下面给出考虑 mini-batch 的矩阵形式。

5.1 批量前向传播

假设 mini-batch 包含 m 个样本,输入矩阵 X 的维度是 (n_0, m)。则:

Z^{[l]} = W^{[l]} @ A^{[l-1]} + b^{[l]}    # (n_l, m)
A^{[l]} = g^{[l]}(Z^{[l]}) # (n_l, m)

注意 b^{[l]} 的维度是 (n_l, 1),在 NumPy 中会自动广播(broadcast)到 (n_l, m)。

5.2 批量反向传播

δ^{[L]} = (A^{[L]} - Y) ⊙ g'^{[L]}(Z^{[L]})        # (n_L, m)
δ^{[l]} = ((W^{[l+1]})^T @ δ^{[l+1]}) ⊙ g'^{[l]}(Z^{[l]}) # (n_l, m)

∂E/∂W^{[l]} = (1/m) * δ^{[l]} @ (A^{[l-1]})^T # (n_l, n_{l-1})
∂E/∂b^{[l]} = (1/m) * Σ_{axis=1} δ^{[l]} # (n_l, 1)

其中求和在样本维度上进行(axis=1),除以 m 得到平均梯度。

6. 激活函数及其导数分析

激活函数的选择对 BP 算法的性能影响巨大。以下是常用激活函数及其导数。

6.1 Sigmoid

$$\sigma(z) = \frac{1}{1 + e^{-z}}$$

导数
$$\sigma'(z) = \sigma(z) \cdot (1 - \sigma(z))$$

性质:输出范围 (0, 1),导数最大值 0.25(在 z=0 处)。

def sigmoid(z):
return 1 / (1 + np.exp(-z))

def sigmoid_derivative(z):
s = sigmoid(z)
return s * (1 - s)

问题:当 |z| 较大时,σ(z) 饱和(趋于 0 或 1),σ’(z) 趋于 0,导致梯度消失。

6.2 Tanh(双曲正切)

$$\tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}}$$

导数
$$\tanh'(z) = 1 - \tanh^2(z)$$

性质:输出范围 (-1, 1),零中心化(zero-centered),缓解了 sigmoid 的非零中心问题。导数最大值为 1(在 z=0 处)。

def tanh_derivative(z):
return 1 - np.tanh(z) ** 2

6.3 ReLU(Rectified Linear Unit)

$$\text{ReLU}(z) = \max(0, z)$$

导数
$$\text{ReLU}'(z) = \begin{cases} 1 & \text{if } z > 0 \\ 0 & \text{if } z \leq 0 \end{cases}$$

def relu(z):
return np.maximum(0, z)

def relu_derivative(z):
return (z > 0).astype(float)

优势:正区间梯度恒为 1,有效缓解梯度消失;计算简单高效。

问题(Dying ReLU):当 z ≤ 0 时梯度为 0,如果某个神经元对几乎所有输入都输出负值,该神经元的权重将永远不会更新——称为”神经元死亡”。这在学习率过大或偏置初始化不当时容易发生。

数值示例:假设某神经元经过 ReLU,初始偏置 b = -0.1,权重 W = [0.01, -0.02]。对于输入 x = [1, 2],z = 0.01×1 + (-0.02)×2 - 0.1 = -0.13 < 0,梯度为 0,参数不再更新。

6.4 LeakyReLU

$$\text{LeakyReLU}(z) = \max(\alpha z, z), \quad \alpha \text{ 通常取 } 0.01$$

导数
$$\text{LeakyReLU}'(z) = \begin{cases} 1 & \text{if } z > 0 \\ \alpha & \text{if } z \leq 0 \end{cases}$$

α 是一个小的正数,保证了负区间的梯度不为零,解决了 Dying ReLU 问题。

6.5 ELU(Exponential Linear Unit)

$$\text{ELU}(z) = \begin{cases} z & \text{if } z > 0 \\ \alpha (e^z - 1) & \text{if } z \leq 0 \end{cases}$$

导数
$$\text{ELU}'(z) = \begin{cases} 1 & \text{if } z > 0 \\ \text{ELU}(z) + \alpha & \text{if } z \leq 0 \end{cases}$$

ELU 的输出均值更接近零,收敛速度通常快于 LeakyReLU。代价是计算指数运算。

6.6 Swish(SiLU)

Google Brain 于 2017 年提出:

$$\text{Swish}(z) = z \cdot \sigma(\beta z)$$

其中 β 是可学习参数或固定常数(β=1 时即 SiLU)。

导数
$$\text{Swish}'(z) = \beta \cdot \text{Swish}(z) + \sigma(\beta z) \cdot (1 - \beta \cdot \text{Swish}(z))$$

Swish 具有非单调的”凸起”形状,在深层网络上表现优于 ReLU。

6.7 激活函数对比表

函数 公式 输出范围 导数范围 零中心 优点 缺点
Sigmoid 1/(1+e^{-z}) (0,1) (0, 0.25] 平滑,可解释为概率 梯度消失,非零中心
Tanh (e^z-e^{-z})/(e^z+e^{-z}) (-1,1) (0, 1] 零中心,比sigmoid更好 仍有饱和问题
ReLU max(0,z) [0,∞) {0,1} 计算快,缓解梯度消失 Dying ReLU
LeakyReLU max(0.01z, z) (-∞,∞) {0.01,1} 解决Dying ReLU α需手动设定
ELU z if z>0 else α(e^z-1) (-α,∞) (0, 1] 近似 输出均值接近零 计算指数
Swish z·σ(z) (-~0.278,∞) (0,~1.1) 非单调,深层表现好 计算开销较大

经验法则

  • 隐藏层首选 ReLU 及其变体
  • 二分类输出层用 Sigmoid
  • 多分类输出层用 Softmax(组合)
  • 回归输出层通常不用激活函数(线性输出)

7. 梯度消失与梯度爆炸

7.1 梯度消失(Vanishing Gradient)的数学根源

梯度消失是早期深度学习难以训练的主要原因。考虑一个深层网络,使用 sigmoid 作为激活函数。

根据链式法则,第 l 层的误差信号:

$$\delta^{[l]} = g'(z^{[l]}) \cdot (W^{[l+1]})^T \cdot \delta^{[l+1]}$$

展开到输出层:

$$\delta^{[l]} = \left( \prod_{i=l+1}^{L} g'(z^{[i]}) \cdot (W^{[i]})^T \right) \cdot (\hat{y} - y)$$

对于 sigmoid,导数最大值仅为 0.25。假设 |W| < 1,那么:

$$\|\delta^{[l]}\| \leq 0.25^{L-l} \cdot \|\hat{y} - y\|$$

当 L - l 较大(即接近输入层)时,误差信号呈指数级衰减——这就是梯度消失。

数值示例:对于 10 层 sigmoid 网络,如果每个 sigmoid 导数的平均值为 0.2,则梯度传播到第一层时约为原始梯度的 0.2^9 ≈ 5.12 × 10^{-7}。这意味着底层参数几乎不更新。

7.2 梯度爆炸(Exploding Gradient)

相反地,如果 |W| > 1(且激活函数导数也较大),梯度可能呈指数增长:

$$\|\delta^{[l]}\| \geq C^{L-l} \cdot \|\hat{y} - y\|, \quad C > 1$$

梯度爆炸会导致参数更新过大,损失函数发散(NaN)。

检测方法

  • 训练损失突然变为 NaN
  • 梯度范数异常增大(例如 > 1000)

7.3 梯度裁剪(Gradient Clipping)

梯度爆炸的标准解决方案是梯度裁剪:

def clip_gradients(grads, max_norm=5.0):
"""按范数裁剪梯度"""
total_norm = 0.0
for param in grads.values():
total_norm += np.sum(param ** 2)
total_norm = np.sqrt(total_norm)

clip_coef = max_norm / (total_norm + 1e-6)
if clip_coef < 1:
for key in grads:
grads[key] *= clip_coef
return grads

或者直接按值裁剪(value clipping):grads = np.clip(grads, -threshold, threshold)

这是 RNN/LSTM 训练的标配操作。

8. 数值梯度检查(Numerical Gradient Checking)

BP 推导容易出错,数值梯度检查是验证反向传播实现正确性的重要工具。

8.1 双边有限差分法

对每个参数 θ_i,用中心差分近似梯度:

$$\frac{\partial E}{\partial \theta_i} \approx \frac{E(\theta_i + \epsilon) - E(\theta_i - \epsilon)}{2\epsilon}$$

其中 ε 通常取 10^{-7} 到 10^{-4} 之间的值。中心差分的截断误差为 O(ε^2),优于单边差分的 O(ε)。

8.2 实现代码

def numerical_gradient(f, theta, epsilon=1e-7):
"""
f: 损失函数,接受参数向量 theta 返回标量
theta: 参数向量(展平后的)
返回: 数值梯度,shape 与 theta 相同
"""
grad = np.zeros_like(theta)
n = len(theta)

for i in range(n):
old_val = theta[i]

# θ + ε
theta[i] = old_val + epsilon
loss_plus = f(theta)

# θ - ε
theta[i] = old_val - epsilon
loss_minus = f(theta)

# 中心差分
grad[i] = (loss_plus - loss_minus) / (2 * epsilon)

# 恢复原值
theta[i] = old_val

return grad

重要:由于需要对每个参数分别进行两次前向传播,数值梯度检查的总复杂度高达 O(参数量 × 前向传播)。因此只能用于小网络的验证,或检查部分参数是否为随机子集。

8.3 梯度比较

比较解析梯度和数值梯度时,使用相对误差:

$$\text{relative error} = \frac{\| \nabla_{analytical} - \nabla_{numerical} \|_2}{\| \nabla_{analytical} \|_2 + \| \nabla_{numerical} \|_2}$$

经验法则:

  • relative error < 10^{-7}:非常好
  • 10^{-7} < relative error < 10^{-5}:可能正确,可复查
  • 10^{-5} < relative error < 10^{-3}:基本不满意,检查个别有问题的参数
  • relative error > 10^{-3}:一定有 bug

常见陷阱

  • 必须在关闭 Dropout、BatchNorm 等随机性的情况下做梯度检查
  • 注意正则化项(L1/L2)也需要计入梯度
  • 使用 double precision(float64),避免浮点精度误差干扰

9. 优化器:BP梯度如何驱动参数更新

BP 计算出了梯度,而优化器决定了如何利用这些梯度来更新参数。

9.1 标准 SGD(Stochastic Gradient Descent)

$$\theta_{t+1} = \theta_t - \eta \cdot \nabla_\theta E(\theta_t)$$

η 是学习率。SGD 的特点是每个 mini-batch 的梯度都有噪声,这有助于逃离局部极小值,但也导致收敛路径震荡。

# 标准 SGD 更新步骤
def sgd_update(params, grads, lr):
for key in params:
params[key] -= lr * grads[key]

9.2 Momentum(带动量的 SGD)

引入速度变量 v,累积历史梯度:

$$v_{t+1} = \gamma v_t + \eta \nabla_\theta E(\theta_t)$$
$$\theta_{t+1} = \theta_t - v_{t+1}$$

γ 通常取 0.9。类比物理:参数是在势能面上运动的粒子,梯度是力,动量是惯性。

解读:如果梯度方向持续一致,v 会累积增大,加速收敛;如果方向反复(震荡),v 会抵消,起到平滑作用。

def momentum_update(params, grads, velocities, lr=0.01, gamma=0.9):
for key in params:
velocities[key] = gamma * velocities[key] + lr * grads[key]
params[key] -= velocities[key]

9.3 Nesterov Accelerated Gradient (NAG)

Nesterov 动量先沿着累积速度方向走一步,然后在”前瞻位置”计算梯度:

$$v_{t+1} = \gamma v_t + \eta \nabla_\theta E(\theta_t - \gamma v_t)$$
$$\theta_{t+1} = \theta_t - v_{t+1}$$

前瞻计算的梯度更准确地反映参数更新后的实际情况,通常比标准动量收敛更快。

9.4 AdaGrad(Adaptive Gradient)

为每个参数维护独立的学习率,根据历史梯度的平方和来自适应调整:

$$G_t = G_{t-1} + (\nabla_\theta E(\theta_t))^2$$
$$\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{G_t + \epsilon}} \odot \nabla_\theta E(\theta_t)$$

优点:适合稀疏特征(每个特征收到不同的有效学习率)。
缺点:G_t 单调递增,学习率不断衰减,可能在达到最优点前就衰减到零。

9.5 RMSprop

解决 AdaGrad 学习率单调衰减的问题,用指数移动平均替代累加和:

$$G_t = \rho \cdot G_{t-1} + (1 - \rho) \cdot (\nabla_\theta E(\theta_t))^2$$
$$\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{G_t + \epsilon}} \odot \nabla_\theta E(\theta_t)$$

ρ 通常取 0.9 到 0.999。这是 Hinton 在 Coursera 课程中提出的非正式算法。

9.6 Adam(Adaptive Moment Estimation)

融合了 Momentum 和 RMSprop 的思想,是目前最常用的优化器:

一阶矩估计(均值)
$$m_t = \beta_1 m_{t-1} + (1 - \beta_1) \nabla_\theta E(\theta_t)$$

二阶矩估计(未中心化的方差)
$$v_t = \beta_2 v_{t-1} + (1 - \beta_2) (\nabla_\theta E(\theta_t))^2$$

偏差校正(因为 m_0 = 0, v_0 = 0,初始步会有偏差):
$$\hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t}$$

参数更新
$$\theta_{t+1} = \theta_t - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}$$

默认超参数:η = 0.001, β_1 = 0.9, β_2 = 0.999, ε = 10^{-8}。

def adam_update(params, grads, m, v, t, lr=0.001, 
beta1=0.9, beta2=0.999, eps=1e-8):
for key in params:
# 更新一阶矩和二阶矩
m[key] = beta1 * m[key] + (1 - beta1) * grads[key]
v[key] = beta2 * v[key] + (1 - beta2) * (grads[key] ** 2)

# 偏差校正
m_hat = m[key] / (1 - beta1 ** t)
v_hat = v[key] / (1 - beta2 ** t)

# 更新参数
params[key] -= lr * m_hat / (np.sqrt(v_hat) + eps)

9.7 优化器对比

优化器 自适应学习率 动量 适用场景
SGD 简单任务,需要手动调节学习率
SGD+Momentum 大多数场景的好的 baseline
Nesterov 是(前瞻) 比 Momentum 略好
AdaGrad 稀疏特征(NLP/推荐系统)
RMSprop RNN/LSTM 训练
Adam 默认首选,大多数场景效果好
AdamW Adam + 解耦权重衰减,现代标准

10. 批归一化(Batch Normalization)

BatchNorm 是 Ioffe 和 Szegedy 在 2015 年提出的技术,它极大地加速了深层网络的训练。理解 BN 的前向和反向传播对于掌握现代 BP 算法至关重要。

10.1 动机

训练过程中,前面层参数的更新会导致后面层输入分布不断变化——称为内部协变量偏移(Internal Covariate Shift)。BN 通过对每一层的激活值进行标准化来解决这个问题,使得每层都能学习在更加稳定的分布上。

10.2 前向传播

给定 mini-batch B = {x_1, x_2, …, x_m}(这里 x_i 是该层的激活值):

步骤1:计算 batch 均值和方差
$$\mu_B = \frac{1}{m} \sum_{i=1}^{m} x_i$$
$$\sigma_B^2 = \frac{1}{m} \sum_{i=1}^{m} (x_i - \mu_B)^2$$

步骤2:标准化
$$\hat{x}_i = \frac{x_i - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}}$$

步骤3:尺度变换和偏移(可学习参数 γ 和 β)
$$y_i = \gamma \cdot \hat{x}_i + \beta$$

注意 γ 和 β 是可学习的参数,使得网络可以在需要时恢复原始的分布(当 γ = σ_B, β = μ_B 时,y_i = x_i)。

10.3 反向传播推导

需要计算 ∂E/∂γ, ∂E/∂β, 以及最重要的 ∂E/∂x_i(传给前一层的梯度)。

已知上游梯度 ∂E/∂y_i。

∂E/∂γ 和 ∂E/∂β(直接计算):

$$\frac{\partial E}{\partial \gamma} = \sum_{i=1}^{m} \frac{\partial E}{\partial y_i} \cdot \hat{x}_i$$
$$\frac{\partial E}{\partial \beta} = \sum_{i=1}^{m} \frac{\partial E}{\partial y_i}$$

∂E/∂x_i(需要通过链式法则展开):

$$\frac{\partial E}{\partial x_i} = \frac{\partial E}{\partial \hat{x}_i} \cdot \frac{\partial \hat{x}_i}{\partial x_i} + \frac{\partial E}{\partial \mu_B} \cdot \frac{\partial \mu_B}{\partial x_i} + \frac{\partial E}{\partial \sigma_B^2} \cdot \frac{\partial \sigma_B^2}{\partial x_i}$$

这是因为 x_i 不仅直接影响 \hat{x}_i,还通过 μ_B 和 σ_B^2 间接影响所有 \hat{x}_j。

展开计算(省略详细推导):

$$\frac{\partial E}{\partial \hat{x}_i} = \frac{\partial E}{\partial y_i} \cdot \gamma$$

$$\frac{\partial E}{\partial \sigma_B^2} = \sum_{i=1}^{m} \frac{\partial E}{\partial \hat{x}_i} \cdot (x_i - \mu_B) \cdot \left(-\frac{1}{2}\right) \cdot (\sigma_B^2 + \epsilon)^{-3/2}$$

$$\frac{\partial E}{\partial \mu_B} = \left(\sum_{i=1}^{m} \frac{\partial E}{\partial \hat{x}_i} \cdot \frac{-1}{\sqrt{\sigma_B^2 + \epsilon}}\right) + \frac{\partial E}{\partial \sigma_B^2} \cdot \frac{-2}{m} \sum_{i=1}^{m} (x_i - \mu_B)$$

最终:
$$\frac{\partial E}{\partial x_i} = \frac{\partial E}{\partial \hat{x}_i} \cdot \frac{1}{\sqrt{\sigma_B^2 + \epsilon}} + \frac{\partial E}{\partial \sigma_B^2} \cdot \frac{2(x_i - \mu_B)}{m} + \frac{\partial E}{\partial \mu_B} \cdot \frac{1}{m}$$

10.4 训练 vs 推理

训练时使用 mini-batch 的统计量;推理时使用训练集上的移动平均:

$$\mu_{running} = \alpha \cdot \mu_{running} + (1 - \alpha) \cdot \mu_B$$
$$\sigma^2_{running} = \alpha \cdot \sigma^2_{running} + (1 - \alpha) \cdot \sigma^2_B$$

α 通常取 0.9 到 0.99。

11. 权重初始化策略

初始化对 BP 算法的效果有决定性影响——糟糕的初始化会导致梯度消失/爆炸,使网络无法训练。

11.1 Xavier(Glorot)初始化

适用于 sigmoid/tanh 激活函数。目标是保持各层激活值和梯度的方差一致。

假设输入维度为 fan_in,输出维度为 fan_out:

$$W_{ij} \sim U\left(-\sqrt{\frac{6}{fan\_in + fan\_out}}, \sqrt{\frac{6}{fan\_in + fan\_out}}\right)$$

或正态分布:
$$W_{ij} \sim \mathcal{N}\left(0, \sqrt{\frac{2}{fan\_in + fan\_out}}\right)$$

11.2 He(Kaiming)初始化

专为 ReLU 激活函数设计。ReLU 会使一半的激活值为零,因此方差被减半。He 初始化通过将方差加倍来补偿:

$$W_{ij} \sim \mathcal{N}\left(0, \sqrt{\frac{2}{fan\_in}}\right)$$

这是使用 ReLU 类激活时的标准选择。

# He 初始化实现
def he_init(shape):
fan_in = shape[1] # 对于 (n_l, n_{l-1}) 的权重矩阵
std = np.sqrt(2.0 / fan_in)
return np.random.randn(*shape) * std

11.3 偏置初始化

偏置通常初始化为 0。但对于 ReLU 网络,常设为小的正数(如 0.01)以避免 Dying ReLU。

12. 学习率调度(Learning Rate Schedules)

学习率是 BP + SGD 中最重要的超参数。现代训练通常使用学习率调度策略。

策略 公式 说明
Step decay η = η_0 × γ^{floor(t/s)} 每 s 轮衰减 γ 倍
Exponential η = η_0 × γ^t 指数衰减
Cosine η = 0.5η_0 × (1 + cos(π×t/T)) 余弦退火,常用
Plateau 监控验证损失,停滞时衰减 自适应
OneCycleLR 先升后降 超收敛
Warmup 前 k 步线性增长 大 batch 训练必需

Warmup 的重要性:在大 batch 训练或使用 Adam 时,初始阶段梯度方向不稳定,如果一开始就用大学习率,可能导致训练崩溃。Warmup 策略在前 1000~5000 步将学习率从 0 线性增加到 η_max。

13. 计算图视角与自动微分

现代深度学习框架(PyTorch、TensorFlow、JAX)并非”手动实现 BP”,而是基于计算图(Computational Graph)自动微分(Autograd)机制。

13.1 计算图

神经网络的计算可以表示为一个有向无环图(DAG),其中:

  • 节点表示操作(operation)或变量
  • 边表示数据流

例如 z = Wx + b 的计算图:

x ────┐
├─→ MatMul ──→ Add ──→ z
W ────┘ ↑

b

13.2 反向模式自动微分

BP 本质上就是反向模式自动微分(Reverse-mode Automatic Differentiation)的一个特例。反向模式对于”多输入 → 单输出”(即损失函数)的场景特别高效,恰好与神经网络的训练需求匹配。

前向模式 vs 反向模式

  • 前向模式:适合 f: R^n → R^m 且 n << m(少输入,多输出)
  • 反向模式:适合 f: R^n → R 且 n 很大(多输入,单输出),这正是神经网络的场景

13.3 PyTorch Autograd 示例

import torch

# 创建需要梯度的张量
x = torch.randn(10, 3, requires_grad=True)
W1 = torch.randn(4, 3, requires_grad=True)
b1 = torch.randn(4, 1, requires_grad=True)

# 前向传播
z1 = W1 @ x.T + b1 # (4, 10)
a1 = torch.relu(z1)

# 计算损失
loss = a1.sum()

# 反向传播(自动!)
loss.backward()

# 梯度已自动填充
print(W1.grad.shape) # torch.Size([4, 3])
print(b1.grad.shape) # torch.Size([4, 1])

loss.backward() 内部自动执行了完整的 BP 过程。

13.4 动态图 vs 静态图

特性 PyTorch (动态图) TensorFlow 1.x (静态图)
图构建 每次前向传播动态构建 先定义图,后运行(define-and-run)
调试 易于调试,可直接使用 Python 调试器 需要使用 tf.Session 和专用调试器
控制流 原生 Python if/for 需要 tf.cond / tf.while_loop
优化空间 优化空间较小 图层面可做更多优化
现代趋势 PyTorch 2.0 引入 torch.compile 融合两者 TensorFlow 2.x 转向 eager execution

14. NumPy 实现:完整的 BP 训练示例

下面给出一个完整的 NumPy 实现,训练一个简单的多层网络进行二分类:

import numpy as np

# ========== 激活函数 ==========
def sigmoid(z):
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def sigmoid_derivative(z):
s = sigmoid(z)
return s * (1 - s)

def relu(z):
return np.maximum(0, z)

def relu_derivative(z):
return (z > 0).astype(float)

# ========== 损失函数 ==========
def binary_cross_entropy(y_hat, y):
"""y_hat: 预测概率, y: 真实标签 (0或1)"""
eps = 1e-15
y_hat = np.clip(y_hat, eps, 1 - eps)
return -np.mean(y * np.log(y_hat) + (1 - y) * np.log(1 - y_hat))

# ========== 初始化 ==========
def initialize_parameters(layer_dims):
"""He 初始化"""
params = {}
for l in range(1, len(layer_dims)):
params[f'W{l}'] = np.random.randn(layer_dims[l], layer_dims[l-1]) \
* np.sqrt(2.0 / layer_dims[l-1])
params[f'b{l}'] = np.zeros((layer_dims[l], 1))
return params

# ========== 前向传播 ==========
def forward_propagation(X, params):
cache = {'A0': X}
L = len(params) // 2
A = X

for l in range(1, L):
Z = params[f'W{l}'] @ A + params[f'b{l}']
A = relu(Z)
cache[f'Z{l}'] = Z
cache[f'A{l}'] = A

# 输出层用 sigmoid
Z_L = params[f'W{L}'] @ A + params[f'b{L}']
A_L = sigmoid(Z_L)
cache[f'Z{L}'] = Z_L
cache[f'A{L}'] = A_L

return A_L, cache

# ========== 反向传播 ==========
def backward_propagation(y, params, cache):
m = y.shape[1]
L = len(params) // 2
grads = {}

# 输出层: sigmoid + 交叉熵 -> dZ = A - Y
A_L = cache[f'A{L}']
dZ = A_L - y # δ^[L]

for l in reversed(range(1, L + 1)):
A_prev = cache[f'A{l-1}']

grads[f'dW{l}'] = (1 / m) * (dZ @ A_prev.T)
grads[f'db{l}'] = (1 / m) * np.sum(dZ, axis=1, keepdims=True)

if l > 1:
dA = params[f'W{l}'].T @ dZ
dZ = dA * relu_derivative(cache[f'Z{l-1}'])

return grads

# ========== Adam 更新 ==========
def adam_update(params, grads, m_adam, v_adam, t,
lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8):
for key in params:
m_adam[key] = beta1 * m_adam[key] + (1 - beta1) * grads[f'd{key}']
v_adam[key] = beta2 * v_adam[key] + (1 - beta2) * (grads[f'd{key}'] ** 2)

m_hat = m_adam[key] / (1 - beta1 ** t)
v_hat = v_adam[key] / (1 - beta2 ** t)

params[key] -= lr * m_hat / (np.sqrt(v_hat) + eps)

# ========== 训练循环 ==========
def train(X, y, layer_dims, epochs=1000, batch_size=64, lr=0.001):
n_samples = X.shape[1]
params = initialize_parameters(layer_dims)

# 初始化 Adam 状态
L = len(layer_dims) - 1
m_adam = {}
v_adam = {}
for l in range(1, L + 1):
m_adam[f'W{l}'] = np.zeros_like(params[f'W{l}'])
v_adam[f'W{l}'] = np.zeros_like(params[f'W{l}'])
m_adam[f'b{l}'] = np.zeros_like(params[f'b{l}'])
v_adam[f'b{l}'] = np.zeros_like(params[f'b{l}'])

for epoch in range(epochs):
# Mini-batch
perm = np.random.permutation(n_samples)
for i in range(0, n_samples, batch_size):
idx = perm[i:i + batch_size]
X_batch, y_batch = X[:, idx], y[:, idx]

# 前向
y_hat, cache = forward_propagation(X_batch, params)

# 反向
grads = backward_propagation(y_batch, params, cache)

# 更新
t = epoch * (n_samples // batch_size) + i // batch_size + 1
adam_update(params, grads, m_adam, v_adam, t, lr=lr)

# 每个 epoch 结束后报告损失
if epoch % 100 == 0:
y_hat_full, _ = forward_propagation(X, params)
loss = binary_cross_entropy(y_hat_full, y)
print(f"Epoch {epoch}, Loss: {loss:.6f}")

return params

# ========== 使用示例 ==========
if __name__ == "__main__":
# 生成玩具数据:两个半月形
from sklearn.datasets import make_moons
X, y = make_moons(n_samples=1000, noise=0.2, random_state=42)
X = X.T # (2, 1000)
y = y.reshape(1, -1) # (1, 1000)

layer_dims = [2, 16, 8, 1] # 2输入 -> 16 -> 8 -> 1输出
trained_params = train(X, y, layer_dims, epochs=2000, batch_size=32)
print("训练完成!")

15. 训练实用技巧总结

15.1 数据预处理

  • 标准化:使每个特征均值为 0,标准差为 1。X = (X - mean) / std
  • 归一化:将特征缩放到 [0, 1] 或 [-1, 1] 区间
  • 不标准化的后果:不同特征的尺度差异会导致权重更新的步长在不同方向差异巨大,优化路径呈 “之” 字形

15.2 梯度相关技巧

  • 定期检查梯度范数:如果梯度持续很小(< 1e-7),可能存在梯度消失
  • 如果损失函数不下降,试试增大学习率或更换优化器
  • 使用 np.isnan(grad).any() 检查梯度是否爆炸

15.3 调试 BP 的步进清单

  1. 先在极小的数据集(如 10 个样本)上训练,确保能过拟合(损失 → 0)
  2. 对随机数据(标签打乱)训练,损失应无法下降——这证明网络有足够容量且实现正确
  3. 用数值梯度检查验证手工推导的梯度
  4. 逐步增加网络深度,观察每一层的梯度范数

16. 面试高频问答

Q1: 请用白板推导 BP 算法中一个隐藏层神经元的权重梯度。要求写出完整的链式法则展开过程。

A: 以三层网络为例,隐藏层神经元 j 连接到输出层神经元 k 的权重 W_{kj}。根据 BP 算法,我们需要计算 ∂E/∂W_{kj}。

第一步:明确 E 是输出层激活值 a_k 的函数,而 a_k 依赖于 z_k,z_k 依赖于 W_{kj}:

∂E/∂W_{kj} = ∂E/∂a_k · ∂a_k/∂z_k · ∂z_k/∂W_{kj}

第二步:分别计算三项:

  • ∂E/∂a_k = a_k - y_k(MSE 损失)
  • ∂a_k/∂z_k = g’(z_k)(激活函数导数)
  • ∂z_k/∂W_{kj} = a_j(前一层激活值)

第三步:定义误差信号 δ_k = (a_k - y_k) · g’(z_k),则:

∂E/∂W_{kj} = δ_k · a_j

写成矩阵形式就是 δ^{[2]} · (a^{[1]})^T。关键是理解为什么定义 δ = ∂E/∂z 作为中间变量——它使得整个反向传播可以递归地逐层计算,极大地简化了表达。


Q2: 为什么 sigmoid + MSE 的组合在深度网络中表现很差?sigmoid + 交叉熵又如何?请从数学角度分析。

A: sigmoid + MSE 的问题出在输出层的反向传播公式:

δ = (ŷ - y) ⊙ σ'(z)

当 σ’(z) 很小时(即 z 的绝对值很大,sigmoid 进入饱和区),即使 (ŷ - y) 很大,δ 也会被 σ’(z) 缩小,导致梯度很小,参数更新缓慢。

而 sigmoid + 交叉熵损失的计算:

E = -[y log ŷ + (1-y) log(1-ŷ)]
∂E/∂z = ∂E/∂ŷ · ∂ŷ/∂z
= [-(y/ŷ) + (1-y)/(1-ŷ)] · ŷ(1-ŷ)
= ŷ - y

σ’(z) = ŷ(1-ŷ) 恰好与损失函数的导数中的项抵消,得到的 δ = ŷ - y 不包含 σ’(z) 因子。这样当预测错误时(ŷ 与 y 差异大),梯度就大,不会因为饱和而停止学习。这是数学上的精妙设计。


Q3: Batch Normalization 在训练和推理时的行为有什么不同?为什么?

A: 在训练时,BN 使用当前 mini-batch 的均值 μ_B 和方差 σ_B^2 进行标准化。这引入了随机性,起到了正则化效果(类似 Dropout)。

在推理(测试/部署)时,BN 使用训练过程中维护的移动平均 μ_running 和 σ^2_running 进行标准化。原因有二:

  1. 推理时可能没有 “batch” 的概念(单样本预测),无法计算 mini-batch 统计量
  2. 推理需要确定性输出,不能依赖当前输入的统计量

此外,BN 的可学习参数 γ 和 β 不受影响——它们在训练和推理时的行为完全一致。


Q4: 什么是 dying ReLU 问题?有哪些解决方案?

A: Dying ReLU 是指 ReLU 神经元对几乎所有输入输出负值,导致该神经元的梯度恒为零,权重永远不会再被更新的现象。常见原因包括:

  • 学习率过大,权重被更新到使 z ≤ 0 的区域
  • 偏置初始化过于负值
  • 高学习率 + 大梯度导致参数 “跳入” 死亡区

解决方案:

  1. LeakyReLU/PReLU:负区间有非零梯度(如 α = 0.01),权重可以继续更新
  2. ELU:负区间平滑且输出均值接近零
  3. 更小的学习率:减少参数 “跳入” 死亡区的概率
  4. 偏置初始化为小的正数:如 b = 0.01,使初始 z > 0
  5. 使用 Adam 等自适应优化器:每个参数有独立的学习率,能更好地平衡

数学上,dying ReLU 的本质是 ReLU 在 z ≤ 0 时 ∂ReLU/∂z = 0,由 BP 的链式法则,这个零会传播给所有依赖它的权重:

∂E/∂W_j = δ_j · a_{prev}
如果 δ_j 因 ReLU 导数而为零,则 ∂E/∂W_j = 0

Q5: 请解释 Adam 优化器中的偏差校正(bias correction)是什么意思,为什么需要它?

A: Adam 维护两个指数移动平均:一阶矩估计 m_t 和二阶矩估计 v_t,初始化时均为零向量。

以 m_t 为例:

m_1 = β_1 · 0 + (1 - β_1) · g_1 = (1 - β_1) · g_1
m_2 = β_1 · m_1 + (1 - β_1) · g_2
= β_1(1 - β_1)g_1 + (1 - β_1)g_2
= (1 - β_1)[β_1 g_1 + g_2]

展开后系数之和是 (1 - β_1) · (β_1 + 1),不等于 1(应为 1 才是有效的加权平均)。

实际上,数学期望 E[m_t] 不等于真实的一阶矩 E[g_t]。可以证明:

E[m_t] = E[g_t] · (1 - β_1^t)

因此需要除以 (1 - β_1^t) 来校正:

m̂_t = m_t / (1 - β_1^t)

当 t 很大时,β_1^t → 0,校正因子趋于 1,校正效果自动消失。这确保在训练的早期阶段(t 较小时),估计值是无偏的,避免初始步长的严重低估导致收敛缓慢。

实践中,不使用偏差校正的 Adam 在训练初期步长会非常小,可能需要数十步才能 “加速” 到正常步长。偏差校正使得 Adam 从第一步起就以合理的步长前进。

文章作者: Leo·Cheung
文章链接: http://tufusi.com/2021/11/20/%E3%80%90%E7%BB%8F%E5%85%B8%E7%AE%97%E6%B3%95%E3%80%91%E7%A5%9E%E7%BB%8F%E7%BD%91%E7%BB%9CBP%E7%AE%97%E6%B3%95/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ONE·PIECE
打赏
  • 微信
  • 支付宝

评论