原文地址

反向传播

反向传播其实很简单,就是梯度下降和链式法则的结合运用。对于多层神经网络反向传播尤其容易理解。

首先梯度下降的基本公式如下:

$$W = W-lr\frac{\partial L}{\partial W}$$

计算损失函数关于权重的梯度,然后梯度下降法更新参数。

不管是卷积、池化、全连接层,假设基本公式为:

$$y=f(x, W, b)$$

$x$ 为上一层的输入,$W$、$b$ 分别是需要训练的参数,$y$ 是输出。

对于每一层神经网络,需要计算三个偏导,$\frac{\partial L}{W}$、$\frac{\partial L}{b}$、$\frac{\partial L}{x}$。对于多层神经网络肯定不能直接对函数进行求导,而是需要利用链式法则、以前向传播的逆序获得上一层的梯度,再乘以该层的梯度即可。

例如:

$$\frac{\partial L}{W}=\frac{\partial L}{f}\frac{\partial f}{W}$$

$$\frac{\partial L}{x}=\frac{\partial L}{f}\frac{\partial f}{x}$$

对于反向传播的上一层的梯度,应为该层的 $\frac{\partial L}{\partial x}$。

用一张图来解释整个神经网络更新参数的过程:

这张图是我自己画的,所以加个水印保护下版权。

所以按照 cs231n 的总结,反向传播有三个步骤:

  1. Identify intermediate functions (forward prop)

  2. Compute local gradients

  3. Combine with upstream error signal to get full gradient


  1. 前向传播计算损失,保留中间变量

  2. 计算局部梯度

  3. 利用链式法则结合上一层的梯度,计算出完整的梯度

最后再根据梯度更新参数。

矩阵求导

按照 cs231n 总结一下矩阵求导的几种情况:

  • Scalar-by-Vector(标量关于向量求导)

$$ \frac { \partial y } { \partial \mathbf { x } } = \left[ \frac { \partial y } { \partial x _ { 1 } } \quad \frac { \partial y } { \partial x _ { 2 } } \ldots \frac { \partial y } { \partial x _ { n } } \right] $$

  • Vector-by-Vector(向量关于向量求导)

$$ \frac { \partial \mathbf { y } } { \partial \mathbf { x } } = \left[ \begin{array} { c c c c } { \frac { \partial y _ { 1 } } { \partial x _ { 1 } } } & { \frac { \partial y _ { 1 } } { \partial x _ { 2 } } } & { \dots } & { \frac { \partial y _ { 1 } } { \partial x _ { n } } } \\ { \vdots } & { \vdots } & { \ddots } & { \vdots } \\ { \frac { \partial y _ { m } } { \partial x _ { 1 } } } & { \frac { \partial y _ { m } } { \partial x _ { 2 } } } & { \cdots } & { \frac { \partial y _ { m } } { \partial x _ { n } } } \end{array} \right] $$

  • Scalar-by-Matrix(标量关于矩阵求导)

$$ \frac { \partial y } { \partial A } = \left[ \begin{array} { c c c c } { \frac { \partial y } { \partial A _ { 11 } } } & { \frac { \partial y } { \partial A _ { 12 } } } & { \dots } & { \frac { \partial y } { \partial A _ { 1 n } } } \\ { \vdots } & { \vdots } & { \ddots } & { \vdots } \\ { \frac { \partial y } { \partial A _ { m 1 } } } & { \frac { \partial y } { \partial A _ { m 2 } } } & { \cdots } & { \frac { \partial y } { \partial A _ { m n } } } \end{array} \right] $$

  • Vector-by-Matrix(向量关于矩阵求导)

$$ \frac { \partial y } { \partial A _ { i j } } = \sigma_i x_j, y=Ax $$

$\sigma$ 为上一层梯度。

下面是 cs231n 总结的深度学习中会用到的矩阵梯度的公式。其中 $W$ 是权重,$x$ 是数据,维度满足矩阵乘法。

分成了两部分求 $z$ 关于权重 $W$ 和输入数据 $x$ 的偏导,$z=Wx$ 和 $z=xW$,$\sigma$ 为上一层梯度。

  • $z=Wx$

$$ \delta = \frac { \partial J } { \partial z }\\ \frac { \partial z } { \partial x } = W\\ \frac { \partial J } { \partial W } = \delta ^ { \top } x $$

  • $z=xW$

$$ \delta = \frac { \partial J } { \partial z }\\ \frac { \partial z } { \partial x } = W ^ { \top }\\ \frac { \partial J } { \partial W } = x ^ { \top } \delta $$

关于矩阵求导,在 cs231n 的 Discussion Section Backpropagation 讨论了,对于损失函数为什么输出一定要是个标量,由于 Dimension Balancing 的原则,最后求偏导一定等于变量的维度,只有这样才能更新参数。

框架实现

Caffe 是在每一个定义的 caffe/layers 中实现一对 ForwardBackward 函数,分别有 GPU 版本和 CPU 版本,GPU 版本定义了其核函数。

以 Sigmoid 和 ReLU 为例:

  • Sigmoid
template <typename Dtype>
void SigmoidLayer<Dtype>::Forward_cpu(const vector<Blob<Dtype>*>& bottom,
    const vector<Blob<Dtype>*>& top) {
  const Dtype* bottom_data = bottom[0]->cpu_data();
  Dtype* top_data = top[0]->mutable_cpu_data();
  const int count = bottom[0]->count();
  for (int i = 0; i < count; ++i) {
    // Sigmoid 函数
    top_data[i] = sigmoid(bottom_data[i]);
  }
}

template <typename Dtype>
void SigmoidLayer<Dtype>::Backward_cpu(const vector<Blob<Dtype>*>& top,
    const vector<bool>& propagate_down,
    const vector<Blob<Dtype>*>& bottom) {
  if (propagate_down[0]) {
    const Dtype* top_data = top[0]->cpu_data();
    const Dtype* top_diff = top[0]->cpu_diff();
    Dtype* bottom_diff = bottom[0]->mutable_cpu_diff();
    const int count = bottom[0]->count();
    for (int i = 0; i < count; ++i) {
      const Dtype sigmoid_x = top_data[i];
      // 上一层梯度乘以该层梯度
      bottom_diff[i] = top_diff[i] * sigmoid_x * (1. - sigmoid_x);
    }
  }
}
  • ReLU
template <typename Dtype>
void ReLULayer<Dtype>::Forward_cpu(const vector<Blob<Dtype>*>& bottom,
    const vector<Blob<Dtype>*>& top) {
  const Dtype* bottom_data = bottom[0]->cpu_data();
  Dtype* top_data = top[0]->mutable_cpu_data();
  const int count = bottom[0]->count();
  // 该参数表明使用 ReLU、Leaky ReLU 还是 PReLU
    // negative_slope=0,ReLU
    // negative_slope=0.01 Leaky ReLU
    // negative_slope=whatever PReLU
  Dtype negative_slope = this->layer_param_.relu_param().negative_slope();
  for (int i = 0; i < count; ++i) {
    // 实现该激活函数
    top_data[i] = std::max(bottom_data[i], Dtype(0))
        + negative_slope * std::min(bottom_data[i], Dtype(0));
  }
}

template <typename Dtype>
void ReLULayer<Dtype>::Backward_cpu(const vector<Blob<Dtype>*>& top,
    const vector<bool>& propagate_down,
    const vector<Blob<Dtype>*>& bottom) {
  if (propagate_down[0]) {
    const Dtype* bottom_data = bottom[0]->cpu_data();
    const Dtype* top_diff = top[0]->cpu_diff();
    Dtype* bottom_diff = bottom[0]->mutable_cpu_diff();
    const int count = bottom[0]->count();
    Dtype negative_slope = this->layer_param_.relu_param().negative_slope();
    for (int i = 0; i < count; ++i) {
      // 计算梯度,大于零就等于 x,小于零的部分只需要乘以 negative_slope
      bottom_diff[i] = top_diff[i] * ((bottom_data[i] > 0)
          + negative_slope * (bottom_data[i] <= 0));
    }
  }
}

PyTorch 是以计算图为基础的自动求导机制,我整理过 PyTorch 的自动求导机制,并实现了一个初中知识里的线性拟合。

一个示例: