SwinT模块,让Swin-Transformer 的使用变得和CNN一样方便快捷!

项目内容

一、SwinT模块的使用演示,接口酷似Conv2D

由于以下两点原因,我们将Swin-Transformer最核心的部分制成了一个类似于nn.Conv2D的接口并命名为SwinT。其输入、输出数据形状完全和Conv2D(CNN)一样,这极大的方便了使用Transformer来编写模型代码。

1、一方面,虽然随着2020年Vit出圈以后,Transformer开始在CV领域得到快速发展;但是对于很多开发者而言,最熟悉的模块依然是CNN,由于Vit内部代码复杂,使得在不同场景下对源代码进行修改以适配实际场景也是一个费时费力的过程。

2、另一方面,使用Transformer的模型通常计算量都巨大,而又因为没有卷积核的先验偏置,通常要使用海量的数据进行预训练(自监督或有监督),使得模型调试成本大;2021年横空出世的Swin-Transformer将注意力运算量从图像尺寸的平方O(n2)降到了线性O(n1),又通过窗口自注意力+滑窗自注意力实现了近似的全局注意力(全局特征提取)。Swin-Transformer也实现CV各大任务的刷榜!如下图,图片来源https://paperswithcode.com/paper/swin-transformer-v2-scaling-up-capacity-and

以下代码展示了完整的SwinT模块的使用

可以观察到,我们传入的核心参数数量实际上和Conv2D是差不多的,因此使用也非常简洁。

重要的是一个SwinT模块可以对图像信息进行高度的抽取,这是CNN难以做到的。

#导入包,miziha中含有SwinT模块
import paddle
import paddle.nn as nn
import miziha

#创建测试数据
test_data = paddle.ones([2, 96, 224, 224]) #[N, C, H, W]
print(f'输入尺寸:{test_data.shape}')

#创建SwinT层
'''
参数:
in_channels: 输入通道数,同卷积
out_channels: 输出通道数,同卷积

以下为SwinT独有的,类似于卷积中的核大小,步幅,填充等
input_resolution: 输入图像的尺寸大小
num_heads: 多头注意力的头数,应该设置为能被输入通道数整除的值
window_size: 做注意力运算的窗口的大小,窗口越大,运算就会越慢
qkv_bias: qkv的偏置,默认None
qk_scale: qkv的尺度,注意力大小的一个归一化,默认None      #Swin-V1版本
dropout: 默认None 
attention_dropout: 默认None 
droppath: 默认None 
downsample: 下采样,默认False,设置为True时,输出的图片大小会变为输入的一半
'''
swint1 = miziha.SwinT(in_channels=96, out_channels=256, input_resolution=(224,224), num_heads=8, window_size=7, downsample=False)
swint2 = miziha.SwinT(in_channels=96, out_channels=256, input_resolution=(224,224), num_heads=8, window_size=7, downsample=True)
conv1 = nn.Conv2D(in_channels=96, out_channels=256, kernel_size=3, stride=1, padding=1)

#前向传播,打印输出形状
output1 = swint1(test_data)
output2 = swint2(test_data)
output3 = conv1(test_data)

print(f'SwinT的输出尺寸:{output1.shape}')
print(f'下采样的SwinT的输出尺寸:{output2.shape}')  #下采样
print(f'Conv2D的输出尺寸:{output3.shape}')  
W1223 13:40:05.217530   174 device_context.cc:447] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 10.1, Runtime API Version: 10.1
W1223 13:40:05.221794   174 device_context.cc:465] device: 0, cuDNN Version: 7.6.


输入尺寸:[2, 96, 224, 224]
SwinT的输出尺寸:[2, 256, 224, 224]
下采样的SwinT的输出尺寸:[2, 256, 112, 112]
Conv2D的输出尺寸:[2, 256, 224, 224]

二、使用SwinT替换Resnet50中Bottleneck中的Conv2D层,创建SwinResnet!

这部分,我们实际展示了如何使用SwinT来替换掉现有模型中相应的Conv2D模块,整个过程对源码改动小;为了展示实际的效果,我们使用Cifar10数据集对模型精度,速度两方面给出了结果,证明了SwinT模块在效果上至少是不差于Conv2D的(这是一个较简单且数据较少的数据集),由于运行整个流程需要6个小时,因此没有过多调节超参数防止过拟合。虽然普通的resnet50可以调高batch来提高速度,但是bacth大小是与模型正则化有关的一个参数,因此将batch都控制在了一个大小进行对比测试。

这里我们给出原Resnet50和具体对其修改的内容,对需要修改的地方进行了标注(修改部分使用了字符串进行注释),为了增加项目的可读性,修改后的SwinResnet放在model.py里。

下图从左到右依次为Resnet, Botnet, SwinResnet中Bottleneck部分

# ResNet模型代码
import numpy as np
import paddle
import paddle.nn as nn
import paddle.nn.functional as F

# ResNet中使用了BatchNorm层,在卷积层的后面加上BatchNorm以提升数值稳定性
# 定义卷积批归一化块
class ConvBNLayer(paddle.nn.Layer):
    def __init__(self,
                 num_channels,
                 num_filters,
                 filter_size,
                 stride=1,
                 groups=1,
                 act=None):
       
        
        # num_channels, 卷积层的输入通道数
        # num_filters, 卷积层的输出通道数
        # stride, 卷积层的步幅
        # groups, 分组卷积的组数,默认groups=1不使用分组卷积
        
        super(ConvBNLayer, self).__init__()

        # 创建卷积层
        self._conv = nn.Conv2D(
            in_channels=num_channels,
            out_channels=num_filters,
            kernel_size=filter_size,
            stride=stride,
            padding=(filter_size - 1) // 2,
            groups=groups,
            bias_attr=False)

        # 创建BatchNorm层
        self._batch_norm = paddle.nn.BatchNorm2D(num_filters)
        
        self.act = act

    def forward(self, inputs):
        y = self._conv(inputs)
        y = self._batch_norm(y)
        if self.act == 'leaky':
            y = F.leaky_relu(x=y, negative_slope=0.1)
        elif self.act == 'relu':
            y = F.relu(x=y)
        return y

# 定义残差块
# 每个残差块会对输入图片做三次卷积,然后跟输入图片进行短接
# 如果残差块中第三次卷积输出特征图的形状与输入不一致,则对输入图片做1x1卷积,将其输出形状调整成一致

class BottleneckBlock(paddle.nn.Layer):
    def __init__(self,
                 num_channels,
                 num_filters,
                 stride,
                 shortcut=True):
        super(BottleneckBlock, self).__init__()
        # 创建第一个卷积层 1x1
        self.conv0 = ConvBNLayer(
            num_channels=num_channels,
            num_filters=num_filters,
            filter_size=1,
            act='relu')

        # 创建第二个卷积层 3x3

        '''
        这里是主要修改的地方,我们将BottleneckBlock中对输入图片做3x3的卷积替换为SwinT,见model.py
        因为模型最终会将图片大小降采样到7x7,因此在最后一个7x7的层改回使用Conv2D
        '''

        self.conv1 = ConvBNLayer(
            num_channels=num_filters,
            num_filters=num_filters,
            filter_size=3,
            stride=stride,
            act='relu')


        # 创建第三个卷积 1x1,但输出通道数乘以4
        self.conv2 = ConvBNLayer(
            num_channels=num_filters,
            num_filters=num_filters * 4,
            filter_size=1,
            act=None)

        # 如果conv2的输出跟此残差块的输入数据形状一致,则shortcut=True
        # 否则shortcut = False,添加1个1x1的卷积作用在输入数据上,使其形状变成跟conv2一致
        if not shortcut:
            self.short = ConvBNLayer(
                num_channels=num_channels,
                num_filters=num_filters * 4,
                filter_size=1,
                stride=stride)

        self.shortcut = shortcut

        self._num_channels_out = num_filters * 4

    def forward(self, inputs):
        y = self.conv0(inputs)
        conv1 = self.conv1(y)

        '''
        将此处self.conv2修改为了self.swin
        '''

        conv2 = self.conv2(conv1)

        # 如果shortcut=True,直接将inputs跟conv2的输出相加
        # 否则需要对inputs进行一次卷积,将形状调整成跟conv2输出一致
        if self.shortcut:
            short = inputs
        else:
            short = self.short(inputs)

        y = paddle.add(x=short, y=conv2)
        y = F.relu(y)
        return y

# 定义ResNet模型
class ResNet(paddle.nn.Layer):
    def __init__(self, layers=50, class_dim=1):

        # layers, 网络层数,可以是50, 101或者152
        # class_dim,分类标签的类别数

        super(ResNet, self).__init__()
        self.layers = layers
        supported_layers = [50, 101, 152]
        assert layers in supported_layers, \
            "supported layers are {} but input layer is {}".format(supported_layers, layers)

        '''
        将depth直接调成了Resnet50的配置,减少代码量
        '''

        if layers == 50:
            #ResNet50包含多个模块,其中第2到第5个模块分别包含3、4、6、3个残差块
            depth = [3, 4, 6, 3]
        elif layers == 101:
            #ResNet101包含多个模块,其中第2到第5个模块分别包含3、4、23、3个残差块
            depth = [3, 4, 23, 3]
        elif layers == 152:
            #ResNet152包含多个模块,其中第2到第5个模块分别包含3、8、36、3个残差块
            depth = [3, 8, 36, 3]
        
        # 残差块中使用到的卷积的输出通道数
        '''
        另外添加了SwinT特有的参数列表,图像尺寸,多头注意力的头数
        '''
        num_filters = [64, 128, 256, 512]

        # ResNet的第一个模块,包含1个7x7卷积,后面跟着1个最大池化层
        self.conv = ConvBNLayer(
            num_channels=3,
            num_filters=64,
            filter_size=7,
            stride=2,
            act='relu')
        self.pool2d_max = nn.MaxPool2D(
            kernel_size=3,
            stride=2,
            padding=1)

        # ResNet的第二到第五个模块c2、c3、c4、c5
        self.bottleneck_block_list = []
        num_channels = 64
        for block in range(len(depth)):
            shortcut = False
            for i in range(depth[block]):
                # c3、c4、c5将会在第一个残差块使用stride=2;其余所有残差块stride=1
                '''
                修改了BottleneckBlock参数的传入,体现在Conv2D和SwinT本身内部运算的差异,
                比如Conv2D有卷积核大小,而SwinT里面则是窗口大小。
                '''
                bottleneck_block = self.add_sublayer(
                    'bb_%d_%d' % (block, i),
                    BottleneckBlock(
                        num_channels=num_channels,
                        num_filters=num_filters[block],
                        stride=2 if i == 0 and block != 0 else 1, 
                        shortcut=shortcut))
                num_channels = bottleneck_block._num_channels_out
                self.bottleneck_block_list.append(bottleneck_block)
                shortcut = True

        # 在c5的输出特征图上使用全局池化
        self.pool2d_avg = paddle.nn.AdaptiveAvgPool2D(output_size=1)

        '''
        修改了模型后面的全连接层,提升模型的学习能力,增加dropout,提升模型的泛化能力
        '''
        # stdv用来作为全连接层随机初始化参数的方差
        import math
        stdv = 1.0 / math.sqrt(2048 * 1.0)
        
        # 创建全连接层,输出大小为类别数目,经过残差网络的卷积和全局池化后,
        # 卷积特征的维度是[B,2048,1,1],故最后一层全连接的输入维度是2048
        self.out = nn.Linear(in_features=2048, out_features=class_dim,
                      weight_attr=paddle.ParamAttr(
                          initializer=paddle.nn.initializer.Uniform(-stdv, stdv)))

    def forward(self, inputs):
        y = self.conv(inputs)
        y = self.pool2d_max(y)
        for bottleneck_block in self.bottleneck_block_list:
            y = bottleneck_block(y)
        y = self.pool2d_avg(y)
        y = paddle.reshape(y, [y.shape[0], -1])
        y = self.out(y)
        return y
使用Cifar10对SwinResnet进行精度及速度上的评估,对比Resnet50。

Mode,参数为0,1,2。0代表评估SwinResnet,1代表评估Resnet50输入尺寸224x224,2代表评估Resnet50输入尺寸32x32。

如果显存溢出,重启内核再运行以下代码就好

Mode = 2    #修改此处即可训练三个不同的模型

import paddle
import paddle.nn as nn
from paddle.vision.models import resnet50, vgg16, LeNet
from paddle.vision.datasets import Cifar10
from paddle.optimizer import Momentum
from paddle.regularizer import L2Decay
from paddle.nn import CrossEntropyLoss
from paddle.metric import Accuracy
from paddle.vision.transforms import Transpose, Resize, Compose
from model import SwinResnet

# 确保从paddle.vision.datasets.Cifar10中加载的图像数据是np.ndarray类型
paddle.vision.set_image_backend('cv2')

# 加载模型
resnet = resnet50(pretrained=False, num_classes=10)
import math
stdv1 = 1.0 / math.sqrt(2048 * 1.0)
stdv2 = 1.0 / math.sqrt(256 * 1.0)
#修改resnet最后一层,加强模型拟合能力
resnet.fc = nn.Sequential(nn.Dropout(0.2),
                nn.Linear(in_features=2048, out_features=256,
                weight_attr=paddle.ParamAttr(
                    initializer=paddle.nn.initializer.Uniform(-stdv1, stdv1))),
                nn.LayerNorm(256),
                nn.Dropout(0.2),
                nn.LeakyReLU(),
                nn.Linear(in_features=256,out_features=10,
                weight_attr=paddle.ParamAttr(
                    initializer=paddle.nn.initializer.Uniform(-stdv2, stdv2)))
                    )
model = SwinResnet(num_classes=10) if Mode == 0 else resnet

#打包模型
model = paddle.Model(model)

# 创建图像变换
transforms = Compose([Resize((224,224)), Transpose()]) if Mode != 2 else Compose([Resize((32, 32)), Transpose()])

# 使用Cifar10数据集
train_dataset = Cifar10(mode='train', transform=transforms)
valid_dadaset = Cifar10(mode='test', transform=transforms)

# 定义优化器
optimizer = Momentum(learning_rate=0.01,
                     momentum=0.9,
                     weight_decay=L2Decay(1e-4),
                     parameters=model.parameters())

# 进行训练前准备
model.prepare(optimizer, CrossEntropyLoss(), Accuracy(topk=(1, 5)))

# 启动训练
model.fit(train_dataset,
          valid_dadaset,
          epochs=40,
          batch_size=80,
          save_dir="./output",
          num_workers=8)
结果分析

以下resnet224指resnet50输入图像尺寸为224x224,resnet32指resnet50输入图像尺寸为32x32。

我们观察到在训练损失和训练集精度上,三个模型(SwinResnet、resnet224、resnet32)达到的效果接近;而在测试精度上,SwinResnet精度达到0.803,Resnet224精度达到0.829,Resnet32精度达到0.716。在精度上,SwinResnet与Resnet224差距不大,由于这是一个小数据集,所以实际上SwinResnet的能力是受限的,而且SwinResnet整体精度是几乎线性的一个提升。 在速度上,SwinResnet为950ms一个batch,而Resnet224是250ms一个batch, 因此运算速度是四倍的差距实际上是可以接受的。

另一方面,我们也发现,由于Cifar10数据集图片大小实际上是32x32的,但是将其插值到224之后再接Resnet竟然比直接接Resnet的精度提高了0.13。这是一个巨大的提升,尽管我们没有引入任何额外的信息量。一个解释是:因为Resnet是用来做Imagenet图片分类的,而图像大小为224x224,因此不适用于32x32图片作为模型的输入,尽管两张图片的信息量完全没有差别。这揭示了卷积核对尺寸大小变化的一个不适应性,难以捕捉不同尺寸物体的信息,这是由于卷积核固定的大小所造成的。

训练损失

训练集精度

测试集精度

三、SwinT的应用场景

1、使用SwinT模块搭建完整的Swin-Transformer模型复现论文

2、可以将现有的骨干为Conv2D的模型替换为SwinT从而搭建性能更好的网络,如Swin-Unet,以及在平常各种场景中需要叠加很多层CNN才能抽取深度特征的地方,可以将几个Conv2D层替换为一个SwinT。

3、由于SwinT输入输出完全同Conv2D,因此也可以用在语义分割、目标检测等复杂任务上。

4、可以同时使用SwinT和Conv2D进行模型搭建,在需要提取高级全局特征的时候使用SwinT在需要局部信息时使用Conv2D,非常灵活。

总结与思考,为什么我们需要注意力机制

本项目的主干部分到上面第三部分就已经结束啦,这部分旨在提供一些启发式的思考,我们将尝试回答以下4个问题:

1、CNN的局限性

2、为什么要降采样

3、只要滑动窗口注意力行吗

4、性能尚未饱和的注意力机制,(Attention is all you need)

一、CNN的局限性

CNN的局限性,因为CNN里面加入了人类的经验偏置(如相比MLP的局部感受野,共享权重等),CNN实际上有以下几种局限性:

1、无法建立相距较远像素点之间的联系,即无法获得全局感受野,另外,即使通过加深网络层数,相聚越远的像素点之间的可行信息传递路径越少,无法做到平均的全局感受野。如下图,橙色点之间的联系必须通过三层卷积才能获得,并且路劲唯一。

2、由上面的实验结果也可知,CNN对于输入图像的尺寸大小比较敏感,这是由卷积核固定大小所导致的,每种网络结构都有它对应的最适宜提取特征的图片尺寸大小,用于Imagenet224训练集的就不适用于Cifar32数据集,除非使用插值将图片resize到224。

3、面对图像的旋转,大幅度位移时,CNN的能力也会受限。

二、为什么要降采样

为什么我们在选择网络结构的时候,总是把中间层设置的要更窄一点呢?我们为什么要把通道数增加而减少图片的尺寸?

实际上任意一张图片都是由很多像素点构成的,而每个三通道像素点自身有
255 ** 3可选取值。一张32x32的图片数量可以拥有255 ** 3 ** (32 * 32)个可选取值,也就是32x32RGB的图片一共有255 ** 3 ** (32 * 32)张。

然而那些有意义,我们可以理解的图像实际上只分布在高维空间中的极小小部分区域(比如使用高斯分布随机生成一张图片,它几乎不可能有一天会生成一张人脸图像,即使这不是不可能事件)参考李宏毅GAN讲解https://aistudio.baidu.com/aistudio/education/lessonvideo/2078966 ,因此我们可以用更低维的特征来表示所输入的图像。我们希望通过压缩图像的尺寸来迫使网络学习到图像的特征,而不是简单的复制图像。利用这些特征,我们才能够使网络去做我们想要做的事情。

另一个关于降采样用的比较多的实际上就是自监督学习例如自编码器。通过编码解码的方式,我们让自编码器的隐藏层学到更加有用的图像特征。

三、只用滑动窗口注意力行吗

Swin-Transformer中注意力机制是如何运行的,如下图。首先,我们对每个颜色内的窗口进行自注意力运算,如[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]。

然后,滑动窗口,可以看作背景黑框在图像上滑动对图像进行的重新切分。

最后,将图像补回原来的大小,这一步是方便代码的编写,并且对窗口中原本不相邻的区域不做注意力运算。注意,窗口是由黑框决定的。也就是说,由于原图像中[4,7,10,13]相邻,因此左上角[4,7,10,13]一起做注意力运算;而[16,11,6,1]原本不相邻,因此右下角[16],[11],[6],[1]单独做注意力运算,而[16],[11]之间不做注意力运算。左下角[12,15],[2,5]各自相邻,因此[12,15]做注意力运算,[2,5]做注意力运算[12,15]和[2,5]之间不做注意力运算。

通过这两步,美妙的事情发生了,我们首先在第一步建立了[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]各自窗口之间的联系,然后在第二步建立了[4,7,10,13]之间的联系,可以观察到,通过这二步,我们得以建立[1,2,3,4,5,6,7,8,9,10,11,12]之间的联系,滑动窗口+原始窗口就如同一个高速通道在图像的左上角和右下角之间建立起了自注意力的联系。
从而获得了全局感受野,我们可以发现,滑窗和不滑窗两步是缺一不可的,只有两者同时存在,我们才能够建立全局的注意力,因此,W-MSA和SW-MSA必须作为一个整体一起使用。

四、性能尚未饱和的注意力机制(Attention is all you need)

在Vit原论文《An image is worth 16x16 words》中,作者提出了关于归纳偏置(先验知识)的结论:在相对较少得数据上,由于Transformer相比于CNN缺少归纳偏置,平移不变和局部感受野等,因此不会表现得很好。在我们这个Cifar10分类的测试中也可以发现这种情况。下图进一步证实了,在少量数据集上使用带有归纳偏置的CNN是好的,但是在更大的数据集上,从数据中学到的模式要比固定的归纳偏置的模式效果更好。现在来看,原论文对这一点还是给出了比较保守的观点。

另外,Vit中也提到了自监督的模型pre-train,现在也被广泛使用,我们可以先使用海量的数据进行预训练,使得从数据中得到一个更有效的’归纳偏置’,然后再使用fine-tune做下游任务,这种效果已经超越了CNN在大量数据集上进行有监督学习的范式。

作者在论文中提到了三个挑战:

1、Vit 由于其他任务,检测分割等

2、继续探索自监督pre-train,因为自监督pre-train的模型与fine-tune之间存在较大的gap。

3、更近一步的vit结构,可能会有更好的表现

今天我们,再来看这篇文章,实际上,可能是因为Vit使用的16x16=256的序列长度,因此没有提到模型计算量上的挑战。

1、3可以认为Swin-Transformer完美的解决了。

2是cv、nlp或其他领域都在积极研究的一个内容,例如近期较火的MAE等。

另外,如何在保证全局感受野的同时进一步减少Transformer的计算量也是一个值得研究的内容,例如如何选择适合的Swin-Transformer窗口的大小。

总结:在小型数据集上,CNN依然有发挥的空间;Transformer的计算量和占用的存储大小是Transformer尚不能完全取代CNN的一个重要原因;在高级任务上,需要抽取全局特征时,Transformer是一个较好的选择。

由于本人才疏学浅,本项目旨在提供一个启发式的思考,希望大家都能有一些收获,但并不代表会完全正确。主页项目上的任何代码问题,欢迎联系qq:3534139536

参考论文

Resnet, https://arxiv.org/abs/1512.03385

Botnet, https://arxiv.org/abs/2101.11605

Swin-Unet, https://arxiv.org/abs/2105.05537

Vit, https://arxiv.org/abs/2010.11929

MAE, https://arxiv.org/pdf/2111.06377.pdf

Swin-Transformer,https://arxiv.org/abs/2103.14030

参考视频

朱毅Vit,https://www.bilibili.com/video/BV15P4y137jb?spm_id_from=333.999.0.0

李沐MAE,https://www.bilibili.com/video/BV1sq4y1q77t?spm_id_from=333.999.0.0

李沐Resnet,https://www.bilibili.com/video/BV1Fb4y1h73E?spm_id_from=333.999.0.0

朱欤Vit10讲,https://aistudio.baidu.com/aistudio/education/lessonvideo/2078605

劈里啪啦Swin,https://www.bilibili.com/video/BV1pL4y1v7jC?from=search&seid=15017869659010796233&spm_id_from=333.337.0.0

李宏毅GAN,https://aistudio.baidu.com/aistudio/education/lessonvideo/2078966

参考代码

paddle计算机视觉教程https://www.paddlepaddle.org.cn/tutorials/projectdetail/2291401

paddleVit,SwinTransformer https://github.com/BR-IDL/PaddleViT/blob/develop/image_classification/SwinTransformer/swin_transformer.py


Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐