Keras入门教程06——CapsNet胶囊神经网络详解及Keras实现

CapsNet胶囊神经网络详解及Keras实现

  • 1. 胶囊神经网络详解
    • 1.1 胶囊神经网络直观理解
      • CNN存在的问题
      • CapsNet的诞生
    • 1.2 CapsNet工作原理
      • 激活函数squash
      • 网络连接方式及$S_j$计算
      • 耦合系数$c_{ij}$计算
      • 动态路由算法原理
      • 使用动态路由算法更新$b_{ij}$
      • 损失函数
  • 2. 代码
  • 3. 具体例子
  • 参考资料

论文 《Dynamic Routing Between Capsules》
参考了一篇博客还有Keras官方的代码,结合代码给大家讲解下胶囊神经网络。

1. 胶囊神经网络详解

1.1 胶囊神经网络直观理解

CNN存在的问题

图像分类中,一旦卷积核检测到类似于眼睛、鼻子、嘴巴等特征,从数据角度上说,就是相关卷积核对鼻子、眼睛等卷积出来值很大,也就是与人脸相关的神经元就相当兴奋。那么就会将图像分类到人脸这一类中。那么就到导致一个问题:**如右图,眼睛、鼻子、嘴巴都有了,CNN理所应当地将其归于人脸。**这就暴露出CNN的两个问题:

  1. 组件的朝向和空间上的相对关系对它来说不重要,它只在乎有没有特征。
  2. 从网络设计上来说,池化层不仅 减少了参数,还可以避免过拟合。但是它的确 抛弃了一些信息,比如位置关系。

再比如说这张图:

尽管拍摄角度不同,但是大脑可以轻易辨识出这些都是同一对象,但 CNN 没有这样的能力。它不能举一反三,其只能通过 扩大训练的数据量 才能达到相似的能力。

CapsNet的诞生

上一列和下一列图片属于同一类,仅仅视角不同。CapsNet和其他模型相比表现好很多。

1.2 CapsNet工作原理

所谓胶囊 ,就是一个向量。它可包含任意个值, 每个值代表了当前需要识别的物体(比如图片)的一个特征。结合之前对传统CNN的学习,我们知道,卷积层的每个 ,都是上一层某一块区域卷积核完成卷积操作,即线性加权求和的结果,它只有 一个值 ,所以是标量。而我们的胶囊网络,它的每个值都是向量,也就是说,这个向量 不仅可表示物体的特征 ,还可以包括物体的方向状态等等。

CapsNet的网络连接方式与全连接网络非常相似,在权重更新、网络输入还有激活函数方面有较大区别,其实代码上并不复杂,奈何,老爷子的文笔……最关键的是公式符号,实在是……好吧,下面根据论文的逻辑给大家一一梳理:

激活函数squash


V j = s q u a s h ( S j ) = ∥ S j ∥ 2 1 + ∥ S j ∥ 2 S j ∥ S j ∥ → ∥ S j ∥ 1 + ∥ S j ∥ 2 S j → ∥ S j ∥ 0.5 + ∥ S j ∥ 2 S j {V_j} =squash(S_j)= \frac{{{{\left\| {{S_j}} \right\|}^2}}}{{1 + {{\left\| {{S_j}} \right\|}^2}}}\frac{{{S_j}}}{{\left\| {{S_j}} \right\|}} \to \frac{{\left\| {{S_j}} \right\|}}{{1 + {{\left\| {{S_j}} \right\|}^2}}}{S_j} \to \frac{{\left\| {{S_j}} \right\|}}{{0.5 + {{\left\| {{S_j}} \right\|}^2}}}{S_j} Vj=squash(Sj)=1+Sj2Sj2SjSj1+Sj2SjSj0.5+Sj2SjSj
其中:

  • j j j——第 j j j个capsule胶囊
  • V j V_j Vj——第 j j j个胶囊的输出向量
  • S j S_j Sj——第 j j j个胶囊的输入向量
  • ∣ ∣ S j ∣ ∣ ||S_j|| Sj S S S向量的模长

我们在这里将1改为0.5, 激活函数的 前一部分 是输入向量 S S S 的的 缩放尺度,后一部分是 S S S单位向量,该激活函数:

  • 保留了输入向量的方向

  • 将输入向量的模压缩到了0-1之间

    也符合我们之前说的:用向量的模的大小衡量某个实体出现的概率,模值越大,概率越大。

代码

def squash(x, axis=-1):
    s_squared_norm = K.sum(K.square(x), axis=axis, keepdims=True) + K.epsilon()  # 即S向量模长的平方
    scale = K.sqrt(s_squared_norm) /(0.5 + s_squared_norm)
    return scale * x

张量Shape没有发生变化,因为代码效率的关系,我们会将论文中的某些公式用更加高效的方式实现,因为这样无需用Python中的for循环,可以借助Keras后端TensorFlow的底层库来提升运算效率。

网络连接方式及 S j S_j Sj计算

其中 S j S_j Sj根据下列公式计算
S j = ∑ i c i j u ^ j ∣ i , u ^ j ∣ i = W i j u i {S_j} = \sum\limits_i^{} {{c_{ij}}{{\hat u}_{j|i}},{{\hat u}_{j|i}} = {W_{ij}}{u_i}} Sj=iciju^ji,u^ji=Wijui
其中:

  • u i {{u_i}} ui为上一层胶囊网络的输出
  • W i j W_{ij} Wij为随机初始化矩阵,可以看做上一层的每个胶囊神经元以不同强度的连接输出到后一层的某个神经元
  • 其中 c i j c_{ij} cij为耦合系数,需要利用 b i j b_{ij} bij计算, b i j b_{ij} bij的更新即Capsule网络的核心——动态路由算法

耦合系数 c i j c_{ij} cij计算

耦合系数根据下面公式计算,即为softmax函数,对 b i j b_{ij} bij进行softmax归一化,该公式由于后面需要特别指定轴,因此需要自己编写,公式如下:
c i j = e b i j ∑ k e b i k {c_{ij}} = \frac{{{e^{{b_{ij}}}}}}{{\sum\limits_k {{e^{{b_{ik}}}}} }} cij=kebikebij
代码

  """定义我们自己的softmax函数
  因为在后面的步骤中,轴发生了交换,因此需要特别指定softmax轴,其余的与softmax一样
  """
  def softmax(x, axis=-1):
      # -K.max(x, axis=axis, keepdims=True)为数值计算方法,不影响softmax结果,只是避免某个数值太大,导致计算误差
      ex = K.exp(x - K.max(x, axis=axis, keepdims=True))
      sum_ex = K.sum(ex, axis=axis, keepdims=True)
      return ex / sum_ex

这里的耦合系数 c i j c_{ij} cij需要利用 b i j b_{ij} bij计算。

动态路由算法原理

我们会将 b i j b_{ij} bij初始化为0,第一次得到的耦合系数 c i j c_{ij} cij值相同,并不能表现出 前一层的胶囊后一层的胶囊 之间的关系。因此我们通过更新 b i j b_{ij} bij来更新 c i j c_{ij} cij b i j b_{ij} bij的更新公式如下:

本论文通过 计算内积 的方式来 改变 b i j b_{ij} bij,从而改变 c i j c_{ij} cij。为什么这么做?如图所示:

点积运算

点积运算接收两个向量,并输出一个标量。 对于给定长度但方向不同的两个向量而言,点积有下列几种情况:正值,零,负值。因此:

  • u i ∣ j {u_{i|j}} uij v j v_j vj 点乘 的结果为正时,代表两个向量指向的方向相似, b i j b_{ij} bij更新结果越大,根据公式(2)那么耦合系数 c i j c_{ij} cij就越高,说明该 u i ∣ j {u_{i|j}} uij v j v_j vj十分匹配。
  • 相反,若点乘结果为负, b i j b_{ij} bij更新结果变小,那么耦合系数 c i j c_{ij} cij变小,说明不匹配。

通过迭代确定 c i j c_{ij} cij。也就确定了一条路线,这条路线上的胶囊神经元的模特别大,路线的尽头就是那个正确预测的胶囊。

使用动态路由算法更新 b i j b_{ij} bij

根据论文的描述,代码如下:

核心代码

输入张量shape:(batch_size, input_num_capsule, input_dim_capsule)

输出张量shape:(batch_size, num_capsule, dim_capsule)

卷积核的kernel_size:(1, input_dim_capsule, num_capsule*dim_capsule)

卷积shape的计算公式是: (w+2p-f)/s+1向下取整

下面的代码删除掉了一些不必要的部分,另外尽量通过向量化以及一维卷积替代全连接运算来提升运算效率:

def call(self, inputs):
    batch_size = K.shape(inputs)[0]  # 批次数目
    input_num_capsule = K.shape(inputs)[1]  # 输入胶囊的个数
    # --------------------- 这里用了一个比较精妙的实现,首先计算输入向量u_hat,其不随动态路由算法迭代改变
    hat_inputs = K.conv1d(inputs, kernel=self.kernel)  # 用一维卷积替代类似的全连接层
    hat_inputs = K.reshape(hat_inputs, shape=(batch_size, input_num_capsule,
                                             self.num_capsule, self.dim_capsule))
    hat_inputs = K.permute_dimensions(hat_inputs, (0, 2, 1, 3))  # 交换轴
    # ---------------------
  	# 动态路由算法更新b
    b = K.zeros_like(hat_inputs[:, :, :, 0])  # 步骤1,初始化为0
    
    for i in range(self.routing):  # 迭代 步骤2-5 r次 
        c = softmax(b, axis=1)  # 步骤2,softmax
        s = K.batch_dot(c, hat_inputs, [2, 2])  # 步骤3,批量矩阵相乘
        o = self.activation(s)  # 步骤4
		if i < self.routings - 1:
                b += K.batch_dot(o, hat_inputs, [2, 3])  # 步骤5
    return o

这里弄不明白没有关系,大神也是从菜鸟一步步过来的,等回过头来再弄明白也不迟。

损失函数

其中 T c T_c Tc代表真实标签, m + = 0.9 m^+=0.9 m+=0.9, m − = 0.1 m_-=0.1 m=0.1,推荐 λ = 0.5 \lambda=0.5 λ=0.5,总损失是所有胶囊损失简单的求和:

def margin_loss(y_true, y_pred):
    lamb, margin = 0.5, 0.1
    loss = y_true * K.square(K.relu((1-margin) - y_pred)) + 
    		lamb * (1-y_true) * K.square(K.relu(y_pred - margin))
    losses = K.sum(loss, axis=-1)  # 这里 * 默认逐元素相乘
    return losses

2. 代码

懒得解释,准备等草稿全部写完再统一整理,有些许参考价值就看吧,之后会有代码详解:

from keras import backend as K
from keras.layers import Layer
from keras import activations
from keras import utils
from keras.datasets import cifar10
from keras.models import Model
from keras.layers import *
from keras.preprocessing.image import ImageDataGenerator
from keras.utils import plot_model
from keras.callbacks import TensorBoard


batch_size = 128
num_classes = 10
epochs = 20

"""
压缩函数,我们使用0.5替代hinton论文中的1,如果是1,所有的向量的范数都将被缩小。
如果是0.5,小于0.5的范数将缩小,大于0.5的将被放大
"""
def squash(x, axis=-1):
    s_quared_norm = K.sum(K.square(x), axis, keepdims=True) + K.epsilon()
    scale = K.sqrt(s_quared_norm) / (0.5 + s_quared_norm)
    result = scale * x
    return result


# 定义我们自己的softmax函数,而不是K.softmax.因为K.softmax不能指定轴
def softmax(x, axis=-1):
    ex = K.exp(x - K.max(x, axis=axis, keepdims=True))
    result = ex / K.sum(ex, axis=axis, keepdims=True)
    return result


# 定义边缘损失,输入y_true, p_pred,返回分数,传入即可fit时候即可
def margin_loss(y_true, y_pred):
    lamb, margin = 0.5, 0.1
    result = K.sum(y_true * K.square(K.relu(1 - margin -y_pred))
    + lamb * (1-y_true) * K.square(K.relu(y_pred - margin)), axis=-1)
    return result


class Capsule(Layer):
    """编写自己的Keras层需要重写3个方法以及初始化方法
    1.build(input_shape):这是你定义权重的地方。
    这个方法必须设self.built = True,可以通过调用super([Layer], self).build()完成。
    2.call(x):这里是编写层的功能逻辑的地方。
    你只需要关注传入call的第一个参数:输入张量,除非你希望你的层支持masking。
    3.compute_output_shape(input_shape):
     如果你的层更改了输入张量的形状,你应该在这里定义形状变化的逻辑,这让Keras能够自动推断各层的形状。
    4.初始化方法,你的神经层需要接受的参数
    """
    def __init__(self,
                 num_capsule,
                 dim_capsule,
                 routings=3,
                 share_weights=True,
                 activation='squash',
                 **kwargs):
        super(Capsule, self).__init__(**kwargs)  # Capsule继承**kwargs参数
        self.num_capsule = num_capsule
        self.dim_capsule = dim_capsule
        self.routings = routings
        self.share_weights = share_weights
        if activation == 'squash':
            self.activation = squash
        else:
            self.activation = activation.get(activation)  # 得到激活函数

    # 定义权重
    def build(self, input_shape):
        input_dim_capsule = input_shape[-1]
        if self.share_weights:
            # 自定义权重
            self.kernel = self.add_weight(
                name='capsule_kernel',
                shape=(1, input_dim_capsule,
                       self.num_capsule * self.dim_capsule),
                initializer='glorot_uniform',
                trainable=True)
        else:
            input_num_capsule = input_shape[-2]
            self.kernel = self.add_weight(
                name='capsule_kernel',
                shape=(input_num_capsule, input_dim_capsule,
                       self.num_capsule * self.dim_capsule),
                initializer='glorot_uniform',
                trainable=True)
        super(Capsule, self).build(input_shape)  # 必须继承Layer的build方法

    # 层的功能逻辑(核心)
    def call(self, inputs):
        if self.share_weights:
            hat_inputs = K.conv1d(inputs, self.kernel)
        else:
            hat_inputs = K.local_conv1d(inputs, self.kernel, [1], [1])

        batch_size = K.shape(inputs)[0]
        input_num_capsule = K.shape(inputs)[1]
        hat_inputs = K.reshape(hat_inputs,
                               (batch_size, input_num_capsule,
                                self.num_capsule, self.dim_capsule))
        hat_inputs = K.permute_dimensions(hat_inputs, (0, 2, 1, 3))

        b = K.zeros_like(hat_inputs[:, :, :, 0])
        for i in range(self.routings):
            c = softmax(b, 1)
            o = self.activation(K.batch_dot(c, hat_inputs, [2, 2]))
            if K.backend() == 'theano':
                o = K.sum(o, axis=1)
            if i < self.routings-1:
                b += K.batch_dot(o, hat_inputs, [2, 3])
                if K.backend() == 'theano':
                    o = K.sum(o, axis=1)
        return o

    def compute_output_shape(self, input_shape):  # 自动推断shape
        return (None, self.num_capsule, self.dim_capsule)


def MODEL():
    input_image = Input(shape=(32, 32, 3))
    x = Conv2D(64, (3, 3), activation='relu')(input_image)
    x = Conv2D(64, (3, 3), activation='relu')(x)
    x = AveragePooling2D((2, 2))(x)
    x = Conv2D(128, (3, 3), activation='relu')(x)
    x = Conv2D(128, (3, 3), activation='relu')(x)
    """
    现在我们将它转换为(batch_size, input_num_capsule, input_dim_capsule),然后连接一个胶囊神经层。模型的最后输出是10个维度为16的胶囊网络的长度
    """
    x = Reshape((-1, 128))(x)  # (None, 100, 128) 相当于前一层胶囊(None, input_num, input_dim)
    capsule = Capsule(num_capsule=10, dim_capsule=16, routings=3, share_weights=True)(x)  # capsule-(None,10, 16)
    output = Lambda(lambda z: K.sqrt(K.sum(K.square(z), axis=2)))(capsule)  # 最后输出变成了10个概率值
    model = Model(inputs=input_image, output=output)
    return model


if __name__ == '__main__':
    # 加载数据
    (x_train, y_train), (x_test, y_test) = cifar10.load_data()
    x_train = x_train.astype('float32')
    x_test = x_test.astype('float32')
    x_train /= 255
    x_test /= 255
    y_train = utils.to_categorical(y_train, num_classes)
    y_test = utils.to_categorical(y_test, num_classes)
	
    # 加载模型
    model = MODEL()
    model.compile(loss=margin_loss, optimizer='adam', metrics=['accuracy'])
    model.summary()
    tfck = TensorBoard(log_dir='capsule')
	
    # 训练
    data_augmentation = True
    if not data_augmentation:
        print('Not using data augmentation.')
        model.fit(
            x_train,
            y_train,
            batch_size=batch_size,
            epochs=epochs,
            validation_data=(x_test, y_test),
            callbacks=[tfck],
            shuffle=True)
    else:
        print('Using real-time data augmentation.')
        # This will do preprocessing and realtime data augmentation:
        datagen = ImageDataGenerator(
            featurewise_center=False,  # set input mean to 0 over the dataset
            samplewise_center=False,  # set each sample mean to 0
            featurewise_std_normalization=False,  # divide inputs by dataset std
            samplewise_std_normalization=False,  # divide each input by its std
            zca_whitening=False,  # apply ZCA whitening
            rotation_range=0,  # randomly rotate images in 0 to 180 degrees
            width_shift_range=0.1,  # randomly shift images horizontally
            height_shift_range=0.1,  # randomly shift images vertically
            horizontal_flip=True,  # randomly flip images
            vertical_flip=False)  # randomly flip images

        # Compute quantities required for feature-wise normalization
        # (std, mean, and principal components if ZCA whitening is applied).
        datagen.fit(x_train)

        # Fit the model on the batches generated by datagen.flow().
        model.fit_generator(
            datagen.flow(x_train, y_train, batch_size=batch_size),
            epochs=epochs,
            validation_data=(x_test, y_test),
            callbacks=[tfck],
            workers=4)
    plot_model(model, to_file='model.png', show_shapes=True)

3. 具体例子

回头来看,下图是论文中一个简单的CapsNet网络,只用到了一层胶囊,很好地展示了CapsNet是如何工作的:

输入是一张手写字的图片:

  1. 首先对这张图片做了常规的卷积操作,得到ReLU Conv1。

  2. 再对ReLU Conv1做卷积操作,并将其调整成适用于CapsNet的向量神经层 PrimaryCaps。

  3. PrimaryCapsDigitCaps 层的传播是 CapsNet 和以往 CNN 操作最大的区别,也就是 动态路由算法

  4. 最后 DigitCaps 中一共 10 个向量,每个向量中元素的个数为 16 。

  5. 对这10个向量 求模,求得 模值最大 的那个 向量代表就是图片概率最大的那个分类

    在胶囊网络中:用向量模的大小衡量某个实体出现的概率,模值越大,概率越大。

参考资料

论文《Dynamic Routing Between Capsules》

代码《cifar10_cnn_capsule》

博客《CapsNet ——胶囊网络原理》

最后有些实现细节不明白也没关系,人生苦短,会用就行。

你可能感兴趣的:(keras从入门到精通)