前言
前面讲了很多二维平面上的卷积,甚至用代码实现了一个简单的两层二维卷积网络,但是在实际的情况下,我们使用的更多的是三维矩阵,即矩阵的\(shape\)往往是\([height, width, channels]\)。在这种情况下,我们的卷积核就会多出一个参数来和通道\(channels\)参数进行匹配,即,这个时候,我们的卷积核的\(shape\)会变成\([kernel\_height, kernel\_width, channels]\)。所以接下来就是要弄清楚在这种多通道的情况下,卷积是如何进行反向传播的。
一、带通道的卷积
由于带有多个通道属性,因此,我们可以将每个通道的数据都视为一个二维的矩阵,卷积核也按照通道的数目拆分成多个二维的卷积核数据,因此,当我们分别对这些二维矩阵进行卷积之后,在将所有的结果相加即可得到最后的结果。这就是带有通道数据的卷积方式的本质。反过来,我们也利用这种本质,来进行反向传播。
在本文的模型中,我们假定平面上的卷积为\(plane\_conv(x, kernel, stride)\),再定义带有数据通道的卷积函数为\(conv(x, kernel, stride)\),其中,\(x\)和\(kernel\)均为三维矩阵,格式分别为:\(x.shape: [height, width, channels]\),\(kernel.shape: [kernel\_height, kernel\_width, channels]\),则我们有: \[ conv(x, kernel, stride) = \sum_{i = 0}^{channels} plane\_conv(x_{i}, kernel_{i}, stride) \] 上面的公式的含义就是多通道卷积产生的结果是将各个通道进行分开,然后在每个通道上分别进行二维平面上的卷积,最后将这些二维的卷积结果相加得到的,而事实上,这本身就是多通道卷积的本质含义。
所以当我们对其中的每一通道的\(kernel_{i}\)和\(x_i\)求导时,我们有:
\[
\frac{\partial conv(x, kernel, stride)}{\partial x_i} = \frac{\partial plane\_conv(x_{i}, kernel_{i}, stride)}{\partial x_i}
\]
\[ \frac{\partial conv(x, kernel, stride)}{\partial kernel_i} = \frac{\partial plane\_conv(x_{i}, kernel_{i}, stride)}{\partial kernel_i} \]
现在,我们假设由上层传来的误差为\(\delta = \frac{\partial L}{\partial conv(x, kernel, stride)}\),那么我们在上式的两边同时乘以误差\(\delta\),根据求导的链式法则,我们有:
\[
\frac{\partial L}{\partial conv(x, kernel, stride)} \cdot \frac{\partial conv(x, kernel, stride)}{\partial x_i} = \frac{\partial L}{\partial conv(x, kernel, stride)} \cdot \frac{\partial plane\_conv(x_{i}, kernel_{i}, stride)}{\partial x_i}
\]
\[ \frac{\partial L}{\partial conv(x, kernel, stride)} \cdot \frac{\partial conv(x, kernel, stride)}{\partial kernel_i} = \frac{\partial L}{\partial conv(x, kernel, stride)} \cdot \frac{\partial plane\_conv(x_{i}, kernel_{i}, stride)}{\partial kernel_i} \]
化简之后,我们有:
\[
\frac{\partial L}{\partial x_i} = \frac{\partial L}{\partial conv(x, kernel, stride)} \cdot \frac{\partial plane\_conv(x_{i}, kernel_{i}, stride)}{\partial x_i}
\]
\[ \frac{\partial L}{\partial kernel_i} = \frac{\partial L}{\partial conv(x, kernel, stride)} \cdot \frac{\partial plane\_conv(x_{i}, kernel_{i}, stride)}{\partial kernel_i} \]
上述的两个等式的左边就是我们需要计算的每一个通道的偏导数,即向前传递的误差的偏导数和需要用来更新参数的偏导数,等式右边则变成了接收到的误差矩阵和每个通道上的二维卷积求导数,因此等式右边就变成了我们已经知道的在二维平面上进行反向传播的偏导数求解了。所以我们将问题由三维空间分解到了二维空间,而二维空间上的卷积及其反向传播我们在之前已经花了较多篇幅进行讲解,所以这里不再赘述了。
需要注意的是,上面的推导过程都仅限于一个卷积核,若有多个卷积核则需要在每一个卷积核上利用这个方法进行偏导数的求解。所以,对于多样本的卷积而言,无论是正向传播还是反向传播,时间复杂度都是很高的。
因此,关于多核卷积的反向传播,我们可以总结如下:
1 | conv_backward(error, x, kernel, bias): |
我们使用代码对上述的算法进行一定的验证。
1 | import numpy as np |
程序的运行结果如下:
1 | Loop 0 : 4566.690565697412 |
需要注意的是,上面的代码并不是十分高效的反向传播,相反,在执行效率上,上面的代码显得很低下,但是我们的目的是了解反向传播的过程,因此,这里并没有进行任何优化,以一种尽管低效但却十分直观的方法来演示反向传播。
可以发现,在卷积的反向传播过程中,二维卷积的反向传播是基础,理解了在二维平面上的卷积,我们就可以很容易地推广到带有通道的卷积上以及多样本的卷积上。
二、激活函数
其实,本质上,激活函数\(Activation Function\)可以看作是一种不带有任何参数的层,它的目的就是让输入的数据产生一些非线性的变化。
我们假设经过某一个操作\(Op\)之后,我们需要对输入进行激活(我们记激活函数为\(g(x)\),则,根据上面的描述,我们可以很容易得到:
\[ y = g(Op(x)) \]
假设,我们根据反向传播算法获得的关于激活函数的输出的梯度为:\(\frac{\partial L}{\partial y} = \delta\),则,我们对上面的式子求导数,我们就可以得到:
\[
\frac{\partial y}{\partial Op(x)} = g'(Op(x))
\] 根据求导的链式法则,我们在上式的两边同时乘以\(\frac{\partial L}{\partial y} = \delta\),则有:
\[
\frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial Op(x)} = \frac{\partial L}{\partial Op(x)} = \delta \cdot g'(Op(x))
\]
所以,我们发现,对于激活函数这一个比较特殊的层来说,它的反向传播就是将接受来的误差乘以它自身在输入矩阵上的导数,从而得到需要传递给下一层的误差,而激活函数层因为往往不带有可训练参数,因此也就不存在参数更新的问题。
三、总结
花了这么多篇幅,总算是弄清楚了最最常见的卷积的反向传播的方式,虽然时间花费比较多,但是这些都是值得的,现在已经可以进行自己的深度学习框架的编写了。在这些基础上,可以在花些时间学习GPU编程,将自己的深度学习框架迁移到GPU上运行,从而获得执行效率的大幅提升。