CNN反向传播卷积核翻转

前言

前面煞费苦心地严格按照上下标证明BP,主要就是为了锻炼自己的证明时候的严谨性,那么这里也严格按照上下标的计算方法推导为何卷积的反向传播需要将卷积核旋转180°

粗略证明

回顾一下BP的第 l 层第 i 个偏置的更新

Ebli=j(δl+1j×Wl+1ji)×σ(zli)

这里面的 δl+1j 其实是误差值对第 l+1 层的第 j 个单元的偏置的导数,也就是下一层偏置的梯度,从而构成递归形式的求解,逼格叫法就是链式求导。

再来看CNN,粗略地说,每个卷积核 K 对应的局部输入 X 与输出值 y 可以构成一个简单的BP网络,即

X=[x11x21x12x22]K=[k11k21k12k22]

zy=conv(K,X)+θ=k11x11+k12x12+k21x21+k22x22+θ=σ(z)

这里强调一下,其实正常的卷积是这样的

conv(K,X)=k22x11+k21x12+k12x21+k11x22

但是为了证明方便,我们就不做这种运算了,直接当做相干来做,但是面试什么的人家问卷积,一定要知道相干不是卷积。

假设 X 的每个单元对应偏置是 bij ,这样我们就可以套用BP的那个偏置更新式子去求解 Ebij ,第一项 δl+1j 这一项不管了,链式求解它,后面再说它的边界,即全连接部分的计算方法;第二项 Wl+1ji 代表连接 y 与第 (i,j) 位置的输入神经元的权重 kij ; 最后一项是当前层输入值函数的导数,比如当函数是 sigmoid 的时候 σ(z)=z(1z) ,所以

Ebij=Ezzxijxijbij=δl+1×kij×σ(xij)

看到这里如果你没有疑问,那就是你卷积的前向操作没学好了。如果学过多通道卷积,肯定会问“同一块特征图的所有单元应该共享偏置啊,为什么这里特征图的每个神经元都有一个偏置?”这个问题一般的解决方法是对同一块特征图所求得的偏置向量求和作为当前特征图需要更新的偏置量,详细后面看代码实现。

关键的一步来了,如何使用卷积来实现这个式子的三个乘法,其实主要体现在如何使用卷积来实现 δl+1×kij ,使得计算的结果大小刚好是 X 这么大的维度。如何将图形越卷积维度越大?摈弃CNN中的卷积,单考验你对卷积在图像处理中的操作及其作用,如果想不出来个一二三,建议学习一下那本绿皮的《数字图像处理》,作者好像叫冈萨雷斯,如果喜欢编程,可以看很多视觉库中的卷积操作,比如matlab中关于卷积的三种处理,详见此博客,我们使用full convolution的卷积操作,通过对特征图边缘填充零使其维度变大,然后再执行卷积即可。

针对上例,方法是

[δl+1k11δl+1k21δl+1k12δl+1k22]=conv0000δl+10000,[k22k12k21k11]=conv0000δl+10000,rot(K,180°)

这便说明了,卷积里面反向传播为什么翻转卷积核?这个证明就是原因。

代码实现

matlabdeeplearning toolbox中,将sigmoid作为激活函数,因而实际的当前层的偏置计算方法为:下一层的偏置矩阵先做补零扩充,然后与卷积核的180°翻转做卷积,得到的矩阵与当前层的神经元对应元素做乘法即可,还有一些其它技巧依据代码做补充。

网络预设

先看看如何设计网络,其实主要就是看每层权重和偏置的维度罢了:

池化:

if strcmp(net.layers{l}.type, 's')
    mapsize = mapsize / net.layers{l}.scale;
    assert(all(floor(mapsize)==mapsize), ['Layer ' num2str(l) ' size must be integer. Actual: ' num2str(mapsize)]);
    for j = 1 : inputmaps
        net.layers{l}.b{j} = 0;
    end
end

当前层是池化层的时候,没权重,且偏置为0,主要是因为池化操作只是简单的下采样操作,用于降低卷积后的特征图的维度,不需要使用权重和偏置做运算。

卷积:

if strcmp(net.layers{l}.type, 'c')
    mapsize = mapsize - net.layers{l}.kernelsize + 1;
    fan_out = net.layers{l}.outputmaps * net.layers{l}.kernelsize ^ 2;
    for j = 1 : net.layers{l}.outputmaps  % output map
        fan_in = inputmaps * net.layers{l}.kernelsize ^ 2;
        for i = 1 : inputmaps  % input map
            net.layers{l}.k{i}{j} = (rand(net.layers{l}.kernelsize) - 0.5) * 2 * sqrt(6 / (fan_in + fan_out));
        end
        net.layers{l}.b{j} = 0;
    end
    inputmaps = net.layers{l}.outputmaps;
end

这里注意看一下,针对第 l 个卷积操作连接第 i 个输入特征图和第 j 个输出特征图的卷积核,使用fan_in,fan_out准则初始化,其实这就间接告诉我们,两层特征图之间的卷积参数量为上一层特征图个数 × 下一层特征图个数(卷积核个数) × 卷积核高 × 卷积核宽;针对第 l 层的第 l 次卷积操作的第 j 个输出特征图的共享偏置值设置为0。

前向运算

多通道卷积

我发现很多新手童鞋不知道多通道卷积到底是何种蛇皮操作,只要你记住,经过卷积后得到的特征图个数等于卷积核个数就行了,切记不是卷积核个数乘以输入特征图个数。具体操作是先使用每个卷积核对所有特征图卷积一遍,然后再加和,比如第二个特征图的值的计算方法就是上一层的第1个特征图与第2个卷积核卷积+上一层的第2个特征图与第2个卷积核卷积+ ,一直加到最后一个即可。

for j = 1 : net.layers{l}.outputmaps   % for each output map
    % create temp output map
    z = zeros(size(net.layers{l - 1}.a{1}) - [net.layers{l}.kernelsize - 1 net.layers{l}.kernelsize - 1 0]);
    for i = 1 : inputmaps   % for each input map
        % convolve with corresponding kernel and add to temp output map
        z = z + convn(net.layers{l - 1}.a{i}, net.layers{l}.k{i}{j}, 'valid');
    end
    % add bias, pass through nonlinearity
    net.layers{l}.a{j} = sigm(z + net.layers{l}.b{j});
end

池化

采用均值池化,对上一层的输出特征图对应区域单元求加和平均,可以采用值为1的卷积核,大小由设定的池化区域决定

for j = 1 : inputmaps
    z = convn(net.layers{l - 1}.a{j}, ones(net.layers{l}.scale) / (net.layers{l}.scale ^ 2), 'valid');   % !! replace with variable
    net.layers{l}.a{j} = z(1 : net.layers{l}.scale : end, 1 : net.layers{l}.scale : end, :);
end

全连接

经过多次卷积、池化操作后,为了做分类,我们把最后一层所有特征图拉长并拼接成一个向量,连接标签向量,这就叫全连接。

for j = 1 : numel(net.layers{n}.a)
      sa = size(net.layers{n}.a{j});
      net.fv = [net.fv; reshape(net.layers{n}.a{j}, sa(1) * sa(2), sa(3))];
end

最后计算输出

net.o = sigm(net.ffW * net.fv + repmat(net.ffb, 1, size(net.fv, 2)));

反向传播

全连接部分

反向传播必然是先计算全连接部分的误差值,然后向前推到由特征图拉长的向量的每个神经元的偏置,这也就是上面提到的为什么没有共享偏置的根源,因为在反向传播的第一层便对每个神经元单独求解偏置更新量了。想想之前关于BP总结的那个式子 Δc×W×B×(1B) ,便可以得到误差对于全连接层偏置的偏导数了。

% error
net.e = net.o - y;
% loss function
net.L = 1/2* sum(net.e(:) .^ 2) / size(net.e, 2);

%% backprop deltas
net.od = net.e .* (net.o .* (1 - net.o));   % output delta △c
net.fvd = (net.ffW' * net.od);              % feature vector delta △c X B
if strcmp(net.layers{n}.type, 'c')         % only conv layers has sigm function
    net.fvd = net.fvd .* (net.fv .* (1 - net.fv));
end

然后再把全连接层的列向量分离成最后一层特征图大小的块,因为它本来就是由最后一层拉长的,很容易进行反操作还原回去。

sa = size(net.layers{n}.a{1});
fvnum = sa(1) * sa(2);
for j = 1 : numel(net.layers{n}.a)
    net.layers{n}.d{j} = reshape(net.fvd(((j - 1) * fvnum + 1) : j * fvnum, :), sa(1), sa(2), sa(3));
end

现在技巧来了,我们知道池化层其实就是对卷积层的元素进行简单的处理,比如每块加和求均值,那么我们就可以粗略得将其还原回去,下述代码就是当当前层由池化操作得来的时候,将第此层的偏置更新量扩大成上一层的输入特征图大小:

expand(net.layers{l + 1}.d{j}, [net.layers{l + 1}.scale net.layers{l + 1}.scale 1]) / net.layers{l + 1}.scale ^ 2

然后有一个蛇皮操作就是,理论部分不是说过要计算 σ(xij) 么,换成sigmoid就是 xij(1xij) ,他这里提前进行了下一层偏置更新量与当前层值函数导数的乘积:

net.layers{l}.d{j} = net.layers{l}.a{j} .* (1 - net.layers{l}.a{j}) .* (expand(net.layers{l + 1}.d{j}, [net.layers{l + 1}.scale net.layers{l + 1}.scale 1]) / net.layers{l + 1}.scale ^ 2);

回顾一下我们的偏置导数计算公式:

Ebij=δl+1×kij×σ(xij)

这一行代码直接就完成了 δl+1×σ(xij) 的操作,接下来直接乘以卷积核即可,注意是填充以后与原来卷积核的翻转180°做卷积操作

z = z + convn(net.layers{l + 1}.d{j}, rot180(net.layers{l + 1}.k{i}{j}), 'full');

因为是批量更新,所以需要对所有的偏置向量除以样本总数

for l = 2 : n
        if strcmp(net.layers{l}.type, 'c')
            for j = 1 : numel(net.layers{l}.a)
                for i = 1 : numel(net.layers{l - 1}.a)
                    net.layers{l}.dk{i}{j} = convn(flipall(net.layers{l - 1}.a{i}), net.layers{l}.d{j}, 'valid') / size(net.layers{l}.d{j}, 3);
                end
                net.layers{l}.db{j} = sum(net.layers{l}.d{j}(:)) / size(net.layers{l}.d{j}, 3);
            end
        end
    end

这里蕴含了利用偏置更新量计算权值更新量的操作,这个与BP一毛一样,就是偏置更新量乘以前一层的单元值即可。

还有就是最后说的由于共享卷积核,所以同一卷积核的偏置更新量也应该一样,直接求均值就行

net.dffW = net.od * (net.fv)' / size(net.od, 2); net.dffb = mean(net.od, 2);
相关文章
相关标签/搜索
每日一句
    每一个你不满意的现在,都有一个你没有努力的曾经。
本站公众号
   欢迎关注本站公众号,获取更多程序园信息
开发小院