CNN - Capsule Neural Networks

Capsule


Preface

​ Geoffrey Hinton (他的团队 ? 挂名论文 ? ) 在2017年提出了一种有别与传统深度网络结构的网络。相对于Convolution层纯参数卷积核表示,Capsule网络的基本结构是胶囊,每个胶囊都有表征一定的空间结构的能力。与其说是胶囊网络,个人对这种网络结构的理解是:向量神经网络。本文是对论文 Dynamic Routing Between Capsules 【arxiv链接🔗】 的总结,也包含了复现论文中遇到过的问题的分析。


问题理解

​ Capsule网络与普通网络的区别在哪里?普通卷积网络(Convolution)最突出的特点就是,自动特征的提取。但是由于卷积操作的空间对称性,并且在多层卷积后,特征的空间位置信息发生损失,对于需要明确位置信息的特征无法很好地提取。比如说:给定一张人脸照片,如果人脸照片被PS了,五官的空间位置十分奇怪:

Figure 1. 惊悚的Lena照片

​ 对于第二张图像,网络有可能将其分类为“人”,但是实际上这是怪物。而第三章图像,只不过经过了一个旋转,最后的分类结果也可能并不是“人”。我们希望在卷积的处理过程中,仍然保留相对位置信息,但又不想让整个网络变成R-CNN一样的复杂object detection结构。

​ Capsule结构就是为了解决空间位置信息问题提出的。其基本思想是:Capsule结构可以将图像中的一个物体分解,分解成不同的子结构,而子结构又可以由更加低级的子特征通过空间变换组合得到。

Figure 2. 特征组合

​ 乍一看,和卷积网络貌似很类似,卷积也是低级特征融合到高级特征图中。但Capsule 特征组合依靠的并不是activation函数进行特征融合,其依靠的是“动态路由”方法。


为什么要使用Capsule

Capsule解决的问题

​ 作者在文章中提到:基于HMM-GMM的语音识别方法在神经网络普适之前一直是SOTA方法,但是其致命缺陷是需要的内存空间太大(内存开销是平方级别的复杂度)。RNN网络对于内存的开销则是线性增长级别的,结果会好很多。

​ CNN相对于全连接层也存在这样的内存开销优势:不同位置的参数是共享的。但是在处理存在有特征的空间变换(平移旋转)时,CNN需要有参数的平移副本,才能实现对不同位置的同一特征进行识别。这让我想起了KCF的平移样本生成。KCF在训练时,会将选中目标的部分进行大量平移,以获得足够的多的训练样本。也就是用内存换取训练结果了,也许这在训练样本极其多时不利于训练,并且随着问题规模的变大,这种方法也并不好。并且CNN这种采用参数平移副本识别不同位置的特征的方式,非常不符合生物视觉原理。作者在文中开始就提到:

Human vision ignores irrelevant details by using a carefully determined sequence of fifixation points to ensure that only a tiny fraction of the optic array is ever processed at the highest resolution.

​ 处理视觉特征的时候,应该使用一定的Attention机制,使用非复制的参数,获得图上的特征,并使用简单的位置表示,应该是获取特征的移动位置而非移动(式)获取特征的位置。这种复制方法,必然导致参数占用内存的增加。CNN能够很好地处理平移特征(因为卷积的滑动窗口特性),但是对于其他的Affine Transformation(仿射变换),处理能力较差。Capsule本身就是带有空间位姿表征的,这样可以防止指数性的参数内存消耗。

Capsule的处理原理

​ 在Preface种说到,Capsule网络实际上是向量神经网络。与一般的标量网络不同,Capsule网络每个输出都是向量,并且向量存在其特定的意义:

  • 向量的方向代表了其属性。可以将属性空间每个单独的维度理解为一个坐标轴,在某个方向的分量大小代表了此属性的强度(比如反射率 / intensity / 斜度等等)。也即此向量代表了其在参数空间中的位置。
  • 向量的模长代表了概率。反映的是沿着某一方向的特征向量存在的概率。一个Capsule层上的所有胶囊可能对某个特征产生不同的意见,组合特征时希望能让意见一致的概率最大(Routing by agreement)。

​ 每个Capsule表示的向量都是一个个的“instantiated parameter”,表征了一个个小组件(也许这样的小组件没有CNN抽取出来的特征那么抽象)。由于特征是不断融合的,底层特征抽取将会抽取出极其多的小型特征。每一层Capsule网络都是对上一层capsule的融合,第k层的capsule输出需要经过一个投票机制,才能被融合到1第k+1层的网络中去。当第k+1层网络存在输出后,从第k层网络选取出与第k+1层某个capsule输出最类似的一个低层capsule,增大其对应权重。


网络结构与计算细节

向量处理 - 路由原理

​ 激活函数并没有被大量使用在Capsule网络中,由于向量网络并不方便使用activation,而且一般激活函数并不能满足上一节提到的:模长的概率表征特性。在此处,作者设计了一个这样的归一化函数,被称作 “squash”: \[ \begin{equation}\label{equ:squash} \mathbf{v}_{j} = \frac{\Vert \mathbf{s}_j\Vert^2}{1 +\Vert \mathbf{s}_j\Vert^2}\frac{\mathbf{s}_j}{\Vert \mathbf{s}_j\Vert} \end{equation} \] ​ 使用此归一化方法,不仅可以进行长度归一,实际对模长很短的向量存在更大的非线性惩罚。假设第k层网络存在n个capsule filter,第k+1层存在m个capsule filter。那么\(\mathbb{u_{i}}\)就是第k层中第i个filter的输出向量,从第k层第i个结构到第k+1层第j个结构的输出可以使用weight matrix映射: \[ \mathbf{\hat{u}}_{j|i}=\mathbf{W}_{ij}\mathbf{u}_i \]\(\mathbf{W}_{ij}\)用于维数变换,并且需要综合不同的输入得到下一层某个capsule的输入。比如本文中,\(\mathbf{W}_{ij}\)就是8 * 16的矩阵。\(\mathbf{\hat{u}}_{j|i}\)相当于计算出的先验(上层i送到本层j的一个特征向量)。那么: \[ \begin{equation}\label{equ:possi} \mathbf{s}_{j}=\sum_{i=1}^{n}c_{ij}\mathbf{\hat{u}}_{j|i} \end{equation} \] ​ 就是综合所有上一层的输出,得到本层第j个capsule的输入,使用\(\eqref{equ:squash}\)进行非线性归一化得到\(\mathbf{v}_{j}\)\(c_{ij}\)是概率加权因子,是由\(b_{ij}\)(一个logit值)经过softmax得到的概率。

动态路由过程

内积 - 投票(Agreement)

​ 动态路由部分包含了激活函数的作用,并且在此处取代了normalization的作用。动态路由主要是为了计算\(\mathbf{v}_{j}\),通过迭代的方式求出概率加权因子,本质上是一个数学性的投票过程。开始生成的\(b_{ij}\)都是0,softmax后,所有的输出路径概率都是相同的(均匀分布)。每一个胶囊的输出都相当于是一个带概率(模长)的特征向量prediction。所有的(weight matrix映射的)的组合(概率加权)就是某个高层capsule的输入,高层输出一个归一化后的向量。这个向量只需要与低层的输出向量进行内积即可,内积结果大,表示低层的输出与高层的输出较为符合(两个输出向量的方向较为一致),将会响应增强对应的路由路径。

Figure 3. 基于内积的投票

​ 根据几次迭代就可以确定低层/高层的输出一致性关系。

算法流程理解

​ 整个动态路由算法流程如下:

Figure 4. 动态路由算法流程

翻译与理解:

  • 初始化logit值 为0,使得初始的routing路径概率分布为均匀分布。默认已经获得了经过weight matrix变换的先验输出向量。
  • 开始迭代(迭代次数为r,论文中r = 3)
    • softmax 将logit变换为:\(c_{i}\),转换成符合概率定义的值。
    • 计算本层的加权输入:也就是公式\(\eqref{equ:possi}\)。计算所有低层prediction对应的高层prediction。
    • squash操作,非线性归一化。由于内积需要转换成概率,需要squash让模长小于1。
    • 根据两层的输出计算内积,得到logits更新值。

​ 注意,只有两个连续的capsule层才会存在动态路由。由于动态路由是对低层 / 高层capsule连接特性的建模,低层prediction与高层prediction相符时,低层capsule更有可能与相应高层capsule相连。

loss设计

​ Capsule网络在设计时,设计者为了让其拥有同时区分图上多个数字的能力,数字label使用一个长度为10的向量表示(类似one-hot),prediction中,每个位置存分类为对应值的概率。使用的是margin loss(SVM多分类问题使用的就是margin loss),由于鼓励图像多分类输出,margin loss 鼓励输出在0.9(正类)以及0.1(负类)附近,分类使用的margin loss objective为: \[ \begin{equation}\label{equ:margin} L_k=T_k\;max(0, m^{+} - \Vert\mathbf{v}_k\Vert)^2+\lambda(1-T_k)min(0, m^{-} - \Vert\mathbf{v}_k\Vert)^2 \end{equation} \] ​ 提供label时,如果图像中的数字是对应class k,那么\(T_k = 1\),否则为0。可以看出,\(T_k\)不同情况下:

  • \(T_k\)为1时,\(L_k=T_k\;max(0, m^{+} - \Vert\mathbf{v}_k\Vert)^2\),需要让输出的概率大概为0.9(\(m^+\)=0.9)
  • 反之,\(L_k=\lambda(1-T_k)min(0, m^{-} - \Vert\mathbf{v}_k\Vert)^2\),负类并不要求概率完全为0。为了多数字判定。(\(m^{-}\)=0.1),λ=0.5(影响削弱)

​ 由于这是一个特殊的分类问题,输出会用一个向量表征(长度为16)。那么作者希望,通过16维的特征向量可以重建出原来的数字。作者使用了全连接网络作为decoder:

Figure 5. 文中使用的全连接decoder

​ 输入层是160维的,但我对这个的理解是:digitCaps存在mask,非预测值的数字将会被乘以0。那么160维的输入意义在哪?为什么不使用16维作为输入呢?

​ 不讨论这个设计问题的情况下,reconstruction会引入loss(需要让),相当于capsuleNN提取了图像的主要特征(PCA类似,只用几个主要特征值恢复图像),但结果与原图应该尽量接近。(这在CycleGAN中也有类似的操作,不过对应的是Cycle Consistency Loss)。Full objective: \[ L_{full}=\sum L_k+0.0005 \times L_{reconstruct} \] ​ 为了不让reconstruction loss造成的优化影响过大,需要将其scale到一个较小的值上。


复现 & 问题

​ 实现CapsNet遇到了比较大的困难,发现自己之前实现的那些网络都比较简单,不需要用到太多的Pytorch tensor特性或是torch的API。于是在本次复现论文时,发现在minibatch情形(高维矩阵计算)下,自己明白逻辑,但是不知如何使用Pytorch完成矩阵计算。显然,将sample一个个计算 / 一维一维计算是可以完成算法的逻辑的,但是这样存在问题:

  • slice / index操作 / 分维度计算(小块矩阵运算)容易导致低下的效率以及内存的消耗
  • 代码变得臃肿,不符合多维矩阵的API设计初衷

​ 复现尝试了实现网络结构,但是比较失败(我太菜了)。最后我学习了一下别人的实现,对代码进行了细致的注释:[Github Repository🔗:gram-ai/ capsule-networks]

实现上的一些点

​ 在实现过程中,主要是Capsule Layer的实现比较困难:

  • nn.ModuleList保存胶囊层的结构,比如保存8个相同的Conv2d Filter。对同一输入处理8次,再使用cat方法连接,产生向量输出。
  • nn.Parameter 用于 weight matrix的实现。但我对这个环节产生了一些看法
    • 以上这两种方法都可以自动将参数加入继承了nn.Module的类的.parameter()
  • 高维矩阵运算 不知如何进行(开始练运算规则都不知道)
    • 输入卷积层的输出结果为:(n, 256, 20, 20),n为batch size
    • PrimaryCaps每个胶囊输出的结果应该是:(n, 32, 6, 6, 1)。每个输出需要进行ravel(不同的通道,每个通道内的6 * 6输出),得到(n, 1152, 1) cat之后得到(n, 1152, 8)
    • PrimaryCaps 经过weight matrix之后,输出的prior应该是(shape):(10, n, 1152, 1, 16):
      • x[None, :, :, None, :] @ self.route_weights[:, None, :, :, :]
      • x由(n, 1152, 8) 变为(10, n, 1152, 1, 8),weights(10, 1152, 8, 16)变为:(10, n, 1152, 1, 16)
  • 剩下的主要问题就是:
    • 一些基本API的使用不够熟练,不知道如何进行sum / transpose / max等等。
    • 矩阵维度应该如何进行变换,才能让一个batch不被index / slice操作分割处理。何时加入一个维度,何时squeeze?应该是经验不足,API使用不熟练的问题。

个人看法

​ 关于CapsuleNet,个人有以下看法:

  • MNIST数据集未免太简单了,这样的实验(虽然作者说,关于Capsule网络只进行浅层的分析):

The aim of this paper is not to explore this whole space but simply to show that one fairly straightforward implementation works well and that dynamic routing helps.

  • 我感觉好像Capsule没有太过跳脱出Convolution以及BP结构,算是一种网络结构 / 思想方法上的大(great)创新,但是不能算作(radical)的创新
  • CNN baseline是否太菜了一点?太浅了吧才三层?(可能是MNIST数据集不需要太花的结构)


Appendix A - Pytorch

​ 记录一下实现过程中的一些基础但是没有重视的点。希望不要做调库侠。

torch矩阵处理

torch矩阵乘法的规则

  • 如果只有2D(size长度为2),需要符合矩阵乘法的尺寸对应要求((m,n) (n, k) -> (m, k))
  • 高维矩阵,除了最后两个维度之外,矩阵乘法需要满足:其他维度完全对应 条件。
1
2
3
4
5
6
>>> a = torch.ones((1, 2, 3, 4))
>>> b = torch.ones(1, 2, 4, 5))
>>> b = torch.ones((1, 2, 4, 5))
>>> c = a @ b
>>> c.shape
torch.Size([1, 2, 3, 5])

​ 也即,高维为矩阵乘法不变,最后两维满足矩阵乘法条件。在Pytorch矩阵运算的时候,可能出现矩阵维度不对应的情况,可能需要通过添加维度的方式来进行维度对应。比如本论文中,两层capsule层中,weight matrix的乘法操作:

​ 在本实现中,训练集batch \(x\)卷积 / PrimaryCaps输出为(n, 32 * 36, 8) (进行了一个ravel操作),\(W\) weight matrix是 8 * 16(右乘)的。那么\(xW\)导致维度不对应(输出需要到(n, 10, 16)),那么需要增加维度。如果将\(x\) 变为(10, n, 32 * 36, 1, 8),\(W\)变为(10, n, 32 * 36, 8, 16) (下划线加粗的是增加的对应维度),就可以让输出为(10, n, 32 * 36, 1, 16)。这恰好符合论文中\(\hat u_{i|j}=W_{ij}u_{ij}\)的定义。在Pytorch中,维度增加使用:

1
result = x[None, :, :, None, :] @ W[:, None, :, :, :]

​ None用于增加维度。

torch.sum

​ sum其实是带有两个参数的:

  • dim 指定对矩阵第dim维进行sum操作
  • keepdim=False keepdim将会使矩阵尽可能使用原来的维度进行表示。很显然,sum操作会降维(比如一个二维数组求sum之后,就成了一个一维数组)

​ 实例:对于torch.FloatTensor(range(16)).view((1, 1, 4, 4))的四个维求sum,输出:

1
2
3
4
5
6
7
8
9
10
11
Only sum:  tensor(120.)
Sum dim = 0: tensor([[[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.],
[12., 13., 14., 15.]]])
Sum dim = 1: tensor([[[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.],
[12., 13., 14., 15.]]])
Sum dim = 2: tensor([[[24., 28., 32., 36.]]])
Sum dim = 3: tensor([[[ 6., 22., 38., 54.]]])

​ 关于sum,使用时需要搞清楚其作用维度。得到作用维度之后可以进行一系列操作,如:

  • 平方后sum,求最后一维的和得到模的平方
  • 对应元素相乘后sum,求对应维度的和得到点积结果

torch.transpose

​ 参数很好理解:直接transpose针对一般的二维矩阵,只需要a.transpose()即可。但是高维矩阵,transpose提供了两个可选参数:

  • dim0 and dim1 表示,这两个dim进行互换(实际上可以不理解为transpose,理解为swap)

torch.cat

​ 简单的concatenate函数。存在两个参数:

  • 需要concat的矩阵,不可变时使用tuple,可变可以使用list。
  • dim(进行concat的维度),要么dim是指定的维度,-1显然表示的是最后一维。比如二维矩阵时,dim = 0表示按行方向进行cat,为1时按列方向进行cat。

torch.norm

​ 求范数。对于向量而言,设a为一个tensor。那么a.norm()直接调用输出2-范数。可以带参数:

  • p = order,其实就是p-范数。1就是绝对值,2就是欧几里得。
  • dim(可以是int或者tuple)。torch的维度操作确实容易让人困惑。个人的理解是:传入的dim用于组织元素,对需要组织的维度进行范数计算。比如:
1
2
3
4
5
6
7
8
9
10
11
12
>>> a = torch.arange(16).view(2, 2, 2, 2)
tensor([[[[ 0., 1.],
[ 2., 3.]],
[[ 4., 5.],
[ 6., 7.]]],
[[[ 8., 9.],
[10., 11.]],
[[12., 13.],
[14., 15.]]]])
>>> a.norm(p = 1, dim = (2, 3))
tensor([[ 6., 22.],
[38., 54.]])

​ 可以看出,torch将tensor a的2 / 3维度进行合并,相当于[[a, b], [c, d]],其中a为元素[[0, 1], [2, 3]]。求1-范数即求绝对值之和。需要组织(整合成一个元素)的维度为(2, 3)。二维的例子会更加容易明白。

  • keepdim 和sum一样,norm操作也是降维的。

torch.max / min

​ max/min是存在参数的:dim以及keepdim。dim参数会指定:max/min操作进行的维度。比如一个二维矩阵: \[ A= \begin{pmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \\ 7 & 8 & 9 \end{pmatrix} \] ​ 如果指定A.max(dim = 0),指定在0维度(行)方向上求最大值,也就是每一列(沿着行变化方向)求最大。输出是:

1
torch.return_types.max(values=tensor([7, 8, 9]), indices=tensor([2, 2, 2]))

​ 可以使用解包的方式,左值使用逗号分割。默认情况下会求全局最大值。

torch.argmax / argmin

​ 其实max已经可以输出最大值最小值对应的位置了。arg系列可能稍微快一些,因为不用返回值。dim / keepdim用法与max/min是一致的。

torch.index_select

​ 相当于切片的集成。help中说得很清楚,index_select就是用于取出矩阵中某些元素 / 行列 / 维度的。

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> x = torch.randn(3, 4)
>>> x
tensor([[ 0.1427, 0.0231, -0.5414, -1.0009],
[-0.4664, 0.2647, -0.1228, -1.1068],
[-1.1734, -0.6571, 0.7230, -0.6004]])
>>> indices = torch.tensor([0, 2])
>>> torch.index_select(x, 0, indices)
tensor([[ 0.1427, 0.0231, -0.5414, -1.0009],
[-1.1734, -0.6571, 0.7230, -0.6004]])
>>> torch.index_select(x, 1, indices)
tensor([[ 0.1427, -0.5414],
[-0.4664, -0.1228],
[-1.1734, 0.7230]])

​ dim 用于视角选择。dim = 0时,说明当前index是基于行的,一次取出n行。dim = 1则相对于列进行讨论。

torch.squeeze / unsqueeze

  • squeeze 去除所有维度为1的多于维度。dim用于指定哪些维度可以被操作。
    • squeeze返回一个与原矩阵共享内存的矩阵(相当于一个ref)
    • squeeze可能会在batch训练中,将batch_size = 1造成的第四维将为三维。
  • unsqueeze 就是增加一个维度(为1)。参数与squeeze一致。

“-1”的作用

​ 与矩阵乘法中使用None做索引类似,-1在torch中也有很多作用。比如最常用的:

  • a.view(1, -1)与a.view(-1, 1)。此处-1表示,由系统自主确定此处的值,-1称为推测。但是只有一维可以被推测,高于1维没办法确定性推测。但-1还是有一些奇怪的使用:
  • sum(dim = -1) 此处是什么意思?这与-1作为索引一致。-1为最后一个,则选择最后一维进行sum操作。通常为列操作。