Caffe源码阅读(2) 卷积层

大约半年前写过一篇关于Caffe全连接层的文章,这次更新卷积层的。

一开始笔者先看了卷积层的梯度传导公式,参考了这两篇:

  1. http://ufldl.stanford.edu/tutorial/supervised/ConvolutionalNeuralNetwork/
  2. http://cogprints.org/5869/1/cnn_tutorial.pdf

卷积层的参数的梯度可以这样来求:
$$\begin{align} \nabla_{W_k^{(l)}} J(W,b;x,y) &= \sum_{i=1}^m (a_i^{(l)}) \ast \text{rot90}(\delta_k^{(l+1)},2), \\ \nabla_{b_k^{(l)}} J(W,b;x,y) &= \sum_{a,b} (\delta_k^{(l+1)})_{a,b}. \end{align}$$
看上去比全连接层复杂多了,但其实,他们本质上基本是一样的,依然可以套回全连接层的参数求导公式:
$$\begin{align} \nabla_{W^{(l)}} J(W,b;x,y) &= \delta^{(l+1)} (a^{(l)})^T, \\ \nabla_{b^{(l)}} J(W,b;x,y) &= \delta^{(l+1)}. \end{align}$$
只需要额外增加一步im2col。这一步的意思是将首先将整张图片按照卷积的窗口大小切好(按照stride来切,可以有重叠),然后各自拉成一列。
为啥要怎样做,因为对于这个小窗口内拉成一列的神经元来说来说,它们跟下一层神经元就是全连接了,所以这个小窗口里面的梯度计算就可以按照全连接来计算就可以了。

如果对照着Caffe的卷积层源码来看,就很清晰了。
forward的代码如下,假设没有分group,这段代码的意思是对于一个大小为num_的batch里面的任意一张图片,首先通过im2col展开成多个列向量,之后直接就用wx+b的方式就能够算到输出了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void ConvolutionLayer<Dtype>::Forward_cpu(const vector<Blob<Dtype>*>& bottom,
vector<Blob<Dtype>*>* top) {
for (int i = 0; i < bottom.size(); ++i) {
const Dtype* bottom_data = bottom[i]->cpu_data();
Dtype* top_data = (*top)[i]->mutable_cpu_data();
Dtype* col_data = col_buffer_.mutable_cpu_data();
const Dtype* weight = this->blobs_[0]->cpu_data();
int weight_offset = M_ * K_; // number of filter parameters in a group
int col_offset = K_ * N_; // number of values in an input region / column
int top_offset = M_ * N_; // number of values in an output region / column
for (int n = 0; n < num_; ++n) {
// im2col transformation: unroll input regions for filtering
// into column matrix for multplication.
im2col_cpu(bottom_data + bottom[i]->offset(n), channels_, height_,
width_, kernel_h_, kernel_w_, pad_h_, pad_w_, stride_h_, stride_w_,
col_data);
// Take inner products for groups.
for (int g = 0; g < group_; ++g) {
caffe_cpu_gemm<Dtype>(CblasNoTrans, CblasNoTrans, M_, N_, K_,
(Dtype)1., weight + weight_offset * g, col_data + col_offset * g,
(Dtype)0., top_data + (*top)[i]->offset(n) + top_offset * g);
}
// Add bias.
if (bias_term_) {
caffe_cpu_gemm<Dtype>(CblasNoTrans, CblasNoTrans, num_output_,
N_, 1, (Dtype)1., this->blobs_[1]->cpu_data(),
bias_multiplier_.cpu_data(),
(Dtype)1., top_data + (*top)[i]->offset(n));
}
}
}
}

所以卷积看成是多个局部的全连接。笔者记得在某个地方看到过,实现卷积的方式有两个,一个是像caffe里面的im2col,另外一个是傅里叶变换。而后者的速度比较快,那么看来facebook给出的加速代码应该是用了傅里叶变换咯:)
言归正传,如果能够理解到im2col的作用,那么backward的代码也很容易理解了。
对于bias,直接就是delta(可能还要乘以bias_multiplier_,这个是Caffe自己的功能,默认不开启,即bias_multiplier_=1)

1
2
3
4
5
6
7
8
9
10
// Bias gradient, if necessary.
if (bias_term_ && this->param_propagate_down_[1]) {
top_diff = top[i]->cpu_diff();
for (int n = 0; n < num_; ++n) {
caffe_cpu_gemv<Dtype>(CblasNoTrans, num_output_, N_,
1., top_diff + top[0]->offset(n),

bias_multiplier_.cpu_data(), 1.,
bias_diff);
}
}

对于weights,batch中的每张图片,首先还是用im2col展开,之后用矩阵乘法表示累加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Since we saved memory in the forward pass by not storing all col
// data, we will need to recompute them.
im2col_cpu(bottom_data + (*bottom)[i]->offset(n), channels_, height_,
width_, kernel_h_, kernel_w_, pad_h_, pad_w_,
stride_h_, stride_w_, col_data);
// gradient w.r.t. weight. Note that we will accumulate diffs.
if (this->param_propagate_down_[0]) {
for (int g = 0; g < group_; ++g) {
caffe_cpu_gemm<Dtype>(CblasNoTrans, CblasTrans, M_, K_, N_,
(Dtype)1., top_diff + top[i]->offset(n) + top_offset * g,
col_data + col_offset * g, (Dtype)1.,
weight_diff + weight_offset * g);
}
}

最后是计算需要传回前一层的delta,也是先看成多个独立的全连接,但是最后需要还原成带有空间结构的形状,需要调用逆过程col2im,其实也是一个累加的过程,让每个空间位置的delta累加起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// gradient w.r.t. bottom data, if necessary.
if (propagate_down[i]) {
if (weight == NULL) {
weight = this->blobs_[0]->cpu_data();
}
for (int g = 0; g < group_; ++g) {
caffe_cpu_gemm<Dtype>(CblasTrans, CblasNoTrans, K_, N_, M_,
(Dtype)1., weight + weight_offset * g,

top_diff + top[i]->offset(n) + top_offset * g,
(Dtype)0., col_diff + col_offset * g);
}
// col2im back to the data
col2im_cpu(col_diff, channels_, height_, width_,
kernel_h_, kernel_w_, pad_h_, pad_w_,
stride_h_, stride_w_, bottom_diff + (*bottom)[i]->offset(n));

}

最后附带一提,在“关于卷积”那篇也提到了卷积运算是先要将kernel旋转180度之后再扫过去的。可以看出Caffe源码是没有这一步的,所以最后学出来的“kernel”实际上是应该还要旋转回来才是正确的卷积核。

很久没有更新网站,发现多了不少评论和问题,无法一一回复,如果现在仍有问题请再次留言 :) 2016.03.29