关于softmax回归
看过最清晰的关于softmax回归的文档来源自UFLDL,简单摘录如下。
softmax用于多分类问题,比如0-9的数字识别,共有10个输出,而且这10个输出的概率和加起来应该为1,所以可以用一个softmax操作归一化这10个输出。进一步一般化,假如共有k个输出,softmax的假设可以形式化表示为:
然后给这个假设定义一个loss function,就是softmax回归的loss function咯,形式化如下:
也很直观,对于某个样本i,他对应的gt label是j,那么对于loss function来说,显然只需要关心第k路是否是一个概率很大的值,所以就用一个l{·}的示性函数来表示只关心第
y(i)
y(i)路(即label对应的那一路),其他路都忽略为0。然后log的部分其实就是第k路的概率值取log。最后需要注意到前面还有一个负号。
所以总的来说,这个loss function的意思是说,对于某个样本,我只看他gt对应的那个路子输出的概率,然后取一个-log从最大化概率变成最小化能量。
然后softmax可以求梯度,梯度的公式是:
然后在实际应用中,一般还是要加上一个正则项,或者在UFLDL教程中被称为权重衰减项,于是loss function和回传梯度都多出了一项,变成了:
然后softmax回归就介绍完了,感觉不懂的话具体还是看UFLDL的教程比较好。
Caffe中的实现
注意这里贴的代码是基于笔者所使用的caffe版本的,大概是2015年初的吧,跟目前的最新caffe版本可能有所出入。
在实现细节上,train时候在最后接上SoftmaxWithLossLayer
,test的时候换成SoftmaxLayer
即可。这里可以看loss_layer.hpp
的注释:
1 2 3 4 | * This layer should be preferred over separate * SoftmaxLayer + MultinomialLogisticLossLayer * as its gradient computation is more numerically stable. * At test time, this layer can be replaced simply by a SoftmaxLayer. |
先看softmax_layer.cpp
,由于只会用到他的forward,所以只看forward就好了。代码如下:
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 33 34 35 36 37 | template <typename Dtype> void SoftmaxLayer<Dtype>::Forward_cpu(const vector<Blob<Dtype>*>& bottom, vector<Blob<Dtype>*>* top) { const Dtype* bottom_data = bottom[0]->cpu_data(); Dtype* top_data = (*top)[0]->mutable_cpu_data(); Dtype* scale_data = scale_.mutable_cpu_data(); int num = bottom[0]->num(); int channels = bottom[0]->channels(); int dim = bottom[0]->count() / bottom[0]->num(); int spatial_dim = bottom[0]->height() * bottom[0]->width(); caffe_copy(bottom[0]->count(), bottom_data, top_data); // We need to subtract the max to avoid numerical issues, compute the exp, // and then normalize. for (int i = 0; i < num; ++i) { // initialize scale_data to the first plane caffe_copy(spatial_dim, bottom_data + i * dim, scale_data); for (int j = 0; j < channels; j++) { for (int k = 0; k < spatial_dim; k++) { scale_data[k] = std::max(scale_data[k], bottom_data[i * dim + j * spatial_dim + k]); } } // subtraction caffe_cpu_gemm<Dtype>(CblasNoTrans, CblasNoTrans, channels, spatial_dim, 1, -1., sum_multiplier_.cpu_data(), scale_data, 1., top_data + i * dim); // exponentiation caffe_exp<Dtype>(dim, top_data + i * dim, top_data + i * dim); // sum after exp caffe_cpu_gemv<Dtype>(CblasTrans, channels, spatial_dim, 1., top_data + i * dim, sum_multiplier_.cpu_data(), 0., scale_data); // division for (int j = 0; j < channels; j++) { caffe_div(spatial_dim, top_data + (*top)[0]->offset(i, j), scale_data, top_data + (*top)[0]->offset(i, j)); } } } |
可以看出基本就是softmax的假设时候的实现公式,即这条。
不同之处是先求取max然后所有值先减去了这个max,目的作者也给了注释是数值问题,毕竟之后是要接上e为底的指数运算的,所以值不可以太大,这个操作相当合理。
然后就到了softmax_loss_layer.cpp
了,总共代码不超100行,就全贴在下面了:
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | #include <algorithm> #include <cfloat> #include <vector> #include "caffe/layer.hpp" #include "caffe/util/math_functions.hpp" #include "caffe/vision_layers.hpp" namespace caffe { template <typename Dtype> void SoftmaxWithLossLayer<Dtype>::LayerSetUp( const vector<Blob<Dtype>*>& bottom, vector<Blob<Dtype>*>* top) { LossLayer<Dtype>::LayerSetUp(bottom, top); softmax_bottom_vec_.clear(); softmax_bottom_vec_.push_back(bottom[0]); softmax_top_vec_.clear(); softmax_top_vec_.push_back(&prob_); softmax_layer_->SetUp(softmax_bottom_vec_, &softmax_top_vec_); } template <typename Dtype> void SoftmaxWithLossLayer<Dtype>::Reshape( const vector<Blob<Dtype>*>& bottom, vector<Blob<Dtype>*>* top) { LossLayer<Dtype>::Reshape(bottom, top); softmax_layer_->Reshape(softmax_bottom_vec_, &softmax_top_vec_); if (top->size() >= 2) { // softmax output (*top)[1]->ReshapeLike(*bottom[0]); } } template <typename Dtype> void SoftmaxWithLossLayer<Dtype>::Forward_cpu( const vector<Blob<Dtype>*>& bottom, vector<Blob<Dtype>*>* top) { // The forward pass computes the softmax prob values. softmax_layer_->Forward(softmax_bottom_vec_, &softmax_top_vec_); const Dtype* prob_data = prob_.cpu_data(); const Dtype* label = bottom[1]->cpu_data(); int num = prob_.num(); int dim = prob_.count() / num; int spatial_dim = prob_.height() * prob_.width(); Dtype loss = 0; for (int i = 0; i < num; ++i) { for (int j = 0; j < spatial_dim; j++) { loss -= log(std::max(prob_data[i * dim + static_cast<int>(label[i * spatial_dim + j]) * spatial_dim + j], Dtype(FLT_MIN))); } } (*top)[0]->mutable_cpu_data()[0] = loss / num / spatial_dim; if (top->size() == 2) { (*top)[1]->ShareData(prob_); } } template <typename Dtype> void SoftmaxWithLossLayer<Dtype>::Backward_cpu(const vector<Blob<Dtype>*>& top, const vector<bool>& propagate_down, vector<Blob<Dtype>*>* bottom) { if (propagate_down[1]) { LOG(FATAL) << this->type_name() << " Layer cannot backpropagate to label inputs."; } if (propagate_down[0]) { Dtype* bottom_diff = (*bottom)[0]->mutable_cpu_diff(); const Dtype* prob_data = prob_.cpu_data(); caffe_copy(prob_.count(), prob_data, bottom_diff); const Dtype* label = (*bottom)[1]->cpu_data(); int num = prob_.num(); int dim = prob_.count() / num; int spatial_dim = prob_.height() * prob_.width(); for (int i = 0; i < num; ++i) { for (int j = 0; j < spatial_dim; ++j) { bottom_diff[i * dim + static_cast<int>(label[i * spatial_dim + j]) * spatial_dim + j] -= 1; } } // Scale gradient const Dtype loss_weight = top[0]->cpu_diff()[0]; caffe_scal(prob_.count(), loss_weight / num / spatial_dim, bottom_diff); } } #ifdef CPU_ONLY STUB_GPU(SoftmaxWithLossLayer); #endif INSTANTIATE_CLASS(SoftmaxWithLossLayer); } // namespace caffe |
其实这个函数挺好懂的,总结起来大致是:
- 首先这里直接内置了一个SoftmaxLayer,利用它直接得到概率值prob_
- 之后的forward和backward都很直观了,就是没有正则项的loss function和梯度的实现方式。(这里为啥没有考虑正则项,是因为正则项的代码不是写在这这里的,而是在更新梯度时候再一起考虑的,具体可以看layer的更新代码,会发现考虑了一个叫decay的东西)
- 这里有了spatial_dim的概念后,就可以直接支持做全图的softmax了,具体来可以参考FCN一文中最后做20类分类的概率图的那个全图softmax
-
一开始笔者先看了卷积层的梯度传导公式,参考了这两篇:
卷积层的参数的梯度可以这样来求:
∇W(l)kJ(W,b;x,y)∇b(l)kJ(W,b;x,y)=∑i=1m(a(l)i)∗rot90(δ(l+1)k,2),=∑a,b(δ(l+1)k)a,b. ∇Wk(l)J(W,b;x,y)=∑i=1m(ai(l))∗rot90(δk(l+1),2),∇bk(l)J(W,b;x,y)=∑a,b(δk(l+1))a,b.
看上去比全连接层复杂多了,但其实, 他们本质上基本是一样的,依然可以套回全连接层的参数求导公式:
∇W(l)J(W,b;x,y)∇b(l)J(W,b;x,y)=δ(l+1)(a(l))T,=δ(l+1). ∇W(l)J(W,b;x,y)=δ(l+1)(a(l))T,∇b(l)J(W,b;x,y)=δ(l+1).
只需要额外增加一步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”实际上是应该还要旋转回来才是正确的卷积核。