拆解式解读如何用飞桨复现胶囊神经网络(Capsule Network)

原创
08/30 13:42
阅读数 163

飞桨开发者说】王成,深度学习爱好者,淮阴师范学院,研究方向为计算机视觉图像与视频处理。

Dynamic Routing Between Capsules是 NIPS 2017的一篇论文。

论文作者Geoffrey Hinton,深度学习的开创者之一,反向传播等神经网络经典算法的发明人。他的胶囊网络(Capsule Network)一经发布就震动了整个人工智能领域。这种网络基于一种被Hinton称为胶囊(capsule)的结构,只需要较少的数据就能获得较好的泛化能力,更好的应对模糊性,处理层级结构和位姿。2017年,他发表了囊间动态路由算法,用于胶囊网络。

下面让我们一起来探究Capsule Network网络结构和原理,并使用飞桨进行复现。

下载安装命令

## CPU版本安装命令
pip install -f https://paddlepaddle.org.cn/pip/oschina/cpu paddlepaddle

## GPU版本安装命令
pip install -f https://paddlepaddle.org.cn/pip/oschina/gpu paddlepaddle-gpu

卷积神经网络的不足之处

卷积神经网络(CNN)虽然表现的很优异,但是针对于旋转或元素平移等变换后的图片,却无法做到准确提取特征。

比如,对下图中字母R进行旋转、加边框,CNN会错误地认为下图的三个R是不同的字母。

如下图,有两张图片,它们都是由一个椭圆的轮廓、眼睛、鼻子和嘴巴组成。CNN可以轻而易举地检测到两张图片上的这些特征,并且认为它检测到的是脸。但显然右边图片的眼睛和嘴巴位置改变了,但是CNN仍然识别为一张正常的人脸,它没有处理好子元素之间的位置关系。

这就引出了位姿的概念。位姿结合了对象之间的相对关系,在数值上表示为4维位姿矩阵。三维对象之间的关系可以用位姿表示,位姿的本质是对象的平移和旋转

对于人类而言,可以轻易辨识出下图是自由女神像,尽管所有的图像显示的角度都不一样,这是因为人类对图像的识别并不依赖视角。虽然从没有见过和下图一模一样的图片,但仍然能立刻知道这些是自由女神像。

但是对CNN而言,这个任务非常难,因为它没有内建对三维空间的理解。因此Hinton主张,如果设计一个能理解和处理对象部件间的分层位姿关系的网络结构,那么将对正确地分类和辨识不同位姿的对象有很大帮助。胶囊神经网络就显式地建模了这些关系,能更精准的理解输入的图片信息。

相比CNN,胶囊网络的另一大益处在于,它只需要学习一小部分数据,就能达到最先进的效果(Hinton在他关于CNN错误的著名演说中提到了这一点)。从这个意义上说,胶囊理论实际上更接近人脑的行为。为了学会区分数字,人脑只需要几十条数据,最多几百条数据,而CNN则需要几万条数据才能取得很好的效果。

下图为胶囊神经网络的位姿辨别效果,和其他模型相比,胶囊网络能辨识上一列和下一列的图片属于同一类,但是CNN会认为它们是不同的物品。

此外,人造神经元输出单个标量表示结果,而胶囊可以输出向量作为结果。CNN使用卷积层获取特征矩阵,为了在神经元的活动中实现视角不变性,通过最大池化方法来达成这一点。但是使用最大池化的致命缺点就是丢失了有价值的信息,也没有处理特征之间的相对空间关系。但是在胶囊网络中,特征状态的重要信息将以向量的形式被胶囊封装。

胶囊的工作原理

让我们比较下胶囊与人造神经元。下表中Vector表示向量,scalar表示标量,Operation对比了它们工作原理的差异。

人造神经元可以用如下3个步骤来表示:

1. 输入标量的标量加权: wixi+b

2. 加权输入标量之和:aj = ∑iwixi+b

3. 标量到标量的非线性变换:hj = f(aj)

胶囊具有上面3个步骤的向量版,并新增了输入的仿射变换这一步骤:

1. 输入向量的矩阵乘法:ûj|I = Wijui

2. 输入向量的标量加权: cijûj|I

3. 加权输入向量之和: sj = ∑i cijûj|I

4. 向量到向量的非线性变换:

下面将详剖析这4个步骤的实现原理:

1. 输入向量的矩阵乘法

胶囊接收的输入向量(上图中的U1、U2和U3)来自下层的3个胶囊。这些向量的长度分别编码下层胶囊检测出的相应特征的概率。

2. 输入向量的标量加权

一个底层胶囊如何把信息输出给高层胶囊呢?之前的人造神经元是通过反向传播算法一步步调整权重优化网络,而胶囊则有所不同。

如下图所示,左右两个方形区域分别是两个高层胶囊JK,方形区域内的点是低层胶囊输入的分布。一个低层胶囊通过调整权重C来“决定”将它的输出发送给哪个高层胶囊。调整方式是胶囊在发送输出前,先将输出乘以这个权重,然后发送给与结果更匹配的高层胶囊。

低层胶囊通过加权把向量输入高层胶囊,同时高层胶囊接收到来自低层胶囊的向量。所有输入以红点和蓝点表示。这些点聚集的地方,意味着低层胶囊的预测互相接近。

比如,胶囊JK中都有一组聚集的红点,因为这些胶囊的预测很接近。在胶囊J中,低层胶囊的输出乘以相应的矩阵W后,落在了远离胶囊J中的红色聚集区的地方;而在胶囊K中,它落在红色聚集区边缘,红色聚集区表示了这个高层胶囊的预测结果。低层胶囊具备测量哪个高层胶囊更能接受其输出的机制,并据此自动调整权重,使对应胶囊K的权重C变高,对应胶囊J的权重C变低。

关于权重,我们需要关注:

1. 权重均为非负标量。

2. 对每个低层胶囊i而言,所有权重的总和等于1(经过softmax函数加权)。

3. 对每个低层胶囊i而言,权重的数量等于高层胶囊的数量。

4. 这些权重的数值由迭代动态路由算法确定。

对于每个低层胶囊i而言,其权重定义了传给每个高层胶囊j的输出的概率分布。

3. 加权输入向量之和

这一步表示输入的组合,和通常的人工神经网络类似,只是它是向量的和而不是标量的和。

4. 向量到向量的非线性变换

CapsNet的另一大创新是新颖的非线性激活函数,这个函数接受一个向量,然后在不改变方向的前提下,压缩它的长度到1以下。

实现代码如下:

def squash(self,vector):
         '''
        压缩向量的函数,类似激活函数,向量归一化
        Args:
            vector:一个4维张量 [batch_size,vector_num,vector_units_num,1]
        Returns:
            一个和x形状相同,长度经过压缩的向量
            输入向量|v|(向量长度)越大,输出|v|越接近1
        '''
        vec_abs = fluid.layers.sqrt(fluid.layers.reduce_sum(fluid.layers.square(vector)))  
        scalar_factor = fluid.layers.square(vec_abs) / (1 + fluid.layers.square(vec_abs))  
        vec_squashed = scalar_factor * fluid.layers.elementwise_div(vector, vec_abs) 
        return(vec_squashed)

 

囊间动态路由(精髓所在)

低层胶囊将其输出发送给对此表示“同意”的高层胶囊。这是动态路由算法的精髓。

囊间动态路由算法伪代码

  • 伪代码的第一行指明了算法的输入:低层输入向量经过矩阵乘法得到的û,以及路由迭代次数r。最后一行指明了算法的输出,高层胶囊的向量Vj。

  • 第2行的bij是一个临时变量,存放了低层向量对高层胶囊的权重,它的值会在迭代过程中逐个更新,当开始一轮迭代时,它的值经过softmax转换成cij。在囊间动态路由算法开始时,bij的值被初始化为零(但是经过softmax后会转换成非零且各个权重相等的cij)。

  • 第3行表明第4-7行的步骤会被重复r次(路由迭代次数)。

  • 第4行计算低层胶囊向量的对应所有高层胶囊的权重。bi的值经过softmax后会转换成非零权重ci且其元素总和等于1。

  • 如果是第一次迭代,所有系数cij的值会相等。例如,如果我们有8个低层胶囊和10个高层胶囊,那么所有cij的权重都将等于0.1。这样初始化使不确定性达到最大值:低层胶囊不知道它们的输出最适合哪个高层胶囊。当然,随着这一进程的重复,这些均匀分布将发生改变。

  • 第5行,那里将涉及高层胶囊。这一步计算经前一步确定的路由系数加权后的输入向量的总和,得到输出向量sj。

  • 第7行进行更新权重,这是路由算法的精髓所在。我们将每个高层胶囊的向量vj与低层原来的输入向量û逐元素相乘求和获得内积(也叫点积,点积检测胶囊的输入和输出之间的相似性(下图为示意图)),再用点积结果更新原来的权重bi。这就达到了低层胶囊将其输出发送给具有类似输出的高层胶囊的效果,刻画了向量之间的相似性。这一步骤之后,算法跳转到第3步重新开始这一流程,并重复r次。

   

▲ 点积运算即为向量的内积(点积)运算,

可以表现向量的相似性。

重复次后,我们计算出了所有高层胶囊的输出,并确立正确路由权重。下面是根据上述原理实现的胶囊层:

class Capsule_Layer(fluid.dygraph.Layer):
    def __init__(self,pre_cap_num,pre_vector_units_num,cap_num,vector_units_num):
        '''
        胶囊层的实现类,可以直接同普通层一样使用
        Args:
            pre_vector_units_num(int):输入向量维度 
            vector_units_num(int):输出向量维度 
            pre_cap_num(int):输入胶囊数 
            cap_num(int):输出胶囊数 
            routing_iters(int):路由迭代次数,建议3次 
        Notes:
            胶囊数和向量维度影响着性能,可作为主调参数
        '''
        super(Capsule_Layer,self).__init__()
        self.routing_iters = 3
        self.pre_cap_num = pre_cap_num
        self.cap_num = cap_num
        self.pre_vector_units_num = pre_vector_units_num
        for j in range(self.cap_num):
            self.add_sublayer('u_hat_w'+str(j),fluid.dygraph.Linear(\
            input_dim=pre_vector_units_num,output_dim=vector_units_num))


    def squash(self,vector):
        '''
        压缩向量的函数,类似激活函数,向量归一化
        Args:
            vector:一个4维张量 [batch_size,vector_num,vector_units_num,1]
        Returns:
            一个和x形状相同,长度经过压缩的向量
            输入向量|v|(向量长度)越大,输出|v|越接近1
        '''
        vec_abs = fluid.layers.sqrt(fluid.layers.reduce_sum(fluid.layers.square(vector)))
        scalar_factor = fluid.layers.square(vec_abs) / (1 + fluid.layers.square(vec_abs))
        vec_squashed = scalar_factor * fluid.layers.elementwise_div(vector, vec_abs)
        return(vec_squashed)

    def capsule(self,x,B_ij,j,pre_cap_num):
        '''
        这是动态路由算法的精髓。
        Args:
            x:输入向量,一个四维张量 shape = (batch_size,pre_cap_num,pre_vector_units_num,1)
            B_ij: shape = (1,pre_cap_num,cap_num,1)路由分配权重,这里将会选取(split)其中的第j组权重进行计算
            j:表示当前计算第j个胶囊的路由
            pre_cap_num:输入胶囊数
        Returns:
            v_j:经过多次路由迭代之后输出的4维张量(单个胶囊)
            B_ij:计算完路由之后又拼接(concat)回去的权重
        Notes:
            B_ij,b_ij,C_ij,c_ij注意区分大小写哦
        '''
        x = fluid.layers.reshape(x,(x.shape[0],pre_cap_num,-1))
        u_hat = getattr(self,'u_hat_w'+str(j))(x)
        u_hat = fluid.layers.reshape(u_hat,(x.shape[0],pre_cap_num,-1,1))
        shape_list = B_ij.shape#(1,1152,10,1)
        split_size = [j,1,shape_list[2]-j-1]
        for i in range(self.routing_iters):
            C_ij = fluid.layers.softmax(B_ij,axis=2)
            b_il,b_ij,b_ir = fluid.layers.split(B_ij,split_size,dim=2)
            c_il,c_ij,b_ir = fluid.layers.split(C_ij,split_size,dim=2)
            v_j = fluid.layers.elementwise_mul(u_hat,c_ij) 
v_j = fluid.layers.reduce_sum(v_j,dim=1,keep_dim=True)
            v_j = self.squash(v_j)
            v_j_expand = fluid.layers.expand(v_j,(1,pre_cap_num,1,1))
            u_v_produce = fluid.layers.elementwise_mul(u_hat,v_j_expand)
            u_v_produce = fluid.layers.reduce_sum(u_v_produce,dim=2,keep_dim=True) 
            b_ij += fluid.layers.reduce_sum(u_v_produce,dim=0,keep_dim=True)
            B_ij = fluid.layers.concat([b_il,b_ij,b_ir],axis=2)
        return v_j,B_ij

    def forward(self,x):
        '''
        Args:
            x:shape = (batch_size,pre_caps_num,vector_units_num,1) or (batch_size,C,H,W)
                如果是输入是shape=(batch_size,C,H,W)的张量,
                则将其向量化shape=(batch_size,pre_caps_num,vector_units_num,1)
                满足:C * H * W = vector_units_num * caps_num
                其中 C >= caps_num
        Returns:
            capsules:一个包含了caps_num个胶囊的list
        '''
        if x.shape[3]!=1:
            x = fluid.layers.reshape(x,(x.shape[0],self.pre_cap_num,-1))
            temp_x = fluid.layers.split(x,self.pre_vector_units_num,dim=2)
            temp_x = fluid.layers.concat(temp_x,axis=1)
            x = fluid.layers.reshape(temp_x,(x.shape[0],self.pre_cap_num,-1,1))
            x = self.squash(x)
        B_ij = fluid.layers.ones((1,x.shape[1],self.cap_num,1),dtype='float32')/self.cap_num#
        capsules = []
        for j in range(self.cap_num):
            cap_j,B_ij = self.capsule(x,B_ij,j,self.pre_cap_num)
            capsules.append(cap_j)
        capsules = fluid.layers.concat(capsules,axis=1)
        return capsules   

 

损失函数

将一个10维one-hot编码向量作为标签,该向量由9个零和1个一(正确标签)组成。在损失函数公式中,与正确的标签对应的输出胶囊,系数Tc为1。

如果正确标签是9,这意味着第9个胶囊输出的损失函数的Tc为1,其余9个为0。当Tc为1时,公式中损失函数的右项系数为零,也就是说正确输出项损失函数的值只包含了左项计算;相应的左系数为0,则右项系数为1,错误输出项损失函数的值只包含了右项计算。

|v|为胶囊输出向量的模长,一定程度上表示了类概率的大小,我们再拟定一个量m,用这个变量来衡量概率是否合适。公式右项包括了一个lambda系数以确保训练中的数值稳定性(lambda为固定值0.5),这两项取平方是为了让损失函数符合L2正则。

 def get_loss_v(self,label):
        '''
        计算边缘损失
        Args:
            label:shape=(32,10) one-hot形式的标签
        Notes:
            这里我调用Relu把小于0的值筛除
            m_plus:正确输出项的概率(|v|)大于这个值则loss为0,越接近则loss越小
            m_det:错误输出项的概率(|v|)小于这个值则loss为0,越接近则loss越小
            (|v|即胶囊(向量)的模长)
        '''
        #计算左项,虽然m+是单个值,但是可以通过广播的形式与label(32,10)作差
        max_l =  fluid.layers.relu(train_params['m_plus'] - self.output_caps_v_lenth)
        #平方运算后reshape
        max_l = fluid.layers.reshape(fluid.layers.square(max_l),(train_params['batch_size'],-1))#32,10
        #同样方法计算右项
        max_r =  fluid.layers.relu(self.output_caps_v_lenth - train_params['m_det'])
        max_r = fluid.layers.reshape(fluid.layers.square(max_r),(train_params['batch_size'],-1))#32,10
        #合并的时候直接用one-hot形式的标签逐元素乘算便可
        margin_loss = fluid.layers.elementwise_mul(label,max_l)\
                        + fluid.layers.elementwise_mul(1-label,max_r)*train_params['lambda_val']
        self.margin_loss = fluid.layers.reduce_mean(margin_loss,dim=1)

 

编码器

完整的网络结构分为编码器解码器,我们先来看看编码器。

1. 输入图片28x28首先经过1x256x9x9的卷积层 获得256个20x20的特征图;

2. 用8组256x32x9x9(stride=2)的卷积获得8组32x6x6的特征图;

3. 将获取的特征图向量化输入10个胶囊,这10个胶囊输出向量的长度就是各个类别的概率。

class Capconv_Net(fluid.dygraph.Layer):
    def __init__(self):
        super(Capconv_Net,self).__init__()
        self.add_sublayer('conv0',fluid.dygraph.Conv2D(\
        num_channels=1,num_filters=256,filter_size=(9,9),padding=0,stride = 1,act='relu'))
                for i in range(8):
            self.add_sublayer('conv_vector_'+str(i),fluid.dygraph.Conv2D(\
            num_channels=256,num_filters=32,filter_size=(9,9),stride=2,padding=0,act='relu'))

    def forward(self,x,v_units_num):
        x = getattr(self,'conv0')(x)
        capsules = []
        for i in range(v_units_num):
            temp_x = getattr(self,'conv_vector_'+str(i))(x)
            capsules.append(fluid.layers.reshape(temp_x,(train_params['batch_size'],-1,1,1)))
        x = fluid.layers.concat(capsules,axis=2)        x = self.squash(x)
        return x

从实现代码中我们不难看出特征图转换成向量实际的过程,是将每组二维矩阵展开成一维矩阵(当然有多个二维矩阵则展开后前后拼接);之后再将所有组的一维矩阵在新的维度拼接形成向量(下图为示意图)。根据下面这个思路我经把8次卷积缩小到了一次卷积,本质上脱离循环只用split和concat方法直接向量化,加快了训练效率。

 

解码器

解码器从正确的胶囊中接受一个16维向量,输入经过三个全连接层得到784个像素输出,学习重建一张28×28像素的图像,损失函数为重建图像与输入图像之间的欧氏距离。

下图是我自己训练的网络重构获得的图像,上面是输入网络的原图片,下面是网络重建的图片。

性能评估

说了这么多,胶囊神经网络性能到底如何呢,让我们用同规模CNN+最大池化层来对比一下。

下图是两个网络在其他条件相同情况下,进行1800次迭代的结果。从图中可以看出,虽然胶囊神经网络收敛速度有所不及,但是收敛完成之后更加稳定,CNN+池化层准确率一直处于波动中。

再来玩一下,当训练到一半时将所有图片转置(可以理解为将图片水平垂直翻转+旋转角度,改变位姿)的情况,实验结论如下。

可以明显的看到CNN+池化层在图片转置的情况下准确率直接跌落谷底,在之后的训练中也是一蹶不振(迷失了自我)!但是胶囊神经网络就不一样了,面对截然不同的图片仍然有高于50%的准确率,而且在之后迅速恢复了100%的准确率!甩了CNN+池化层一大截!

Capsule显露了它处理不同位姿的本领!

下图是胶囊数和向量维度对性能的影响。

由于篇幅限制,更多信息可以到AI Studio查看原项目,地址:

https://aistudio.baidu.com/aistudio/projectdetail/657114?shared=1

下载安装命令

## CPU版本安装命令
pip install -f https://paddlepaddle.org.cn/pip/oschina/cpu paddlepaddle

## GPU版本安装命令
pip install -f https://paddlepaddle.org.cn/pip/oschina/gpu paddlepaddle-gpu
展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
在线直播报名
返回顶部
顶部