• 【图像分类】Vision Transformer理论解读+实践测试


    前言

    Vision Transformer是2021年谷歌在ICLR上提出的算法,算法原理并不复杂,但是其价值是开创性的。它首次将NLP领域火热的Transformer模型架构移植到了CV领域,打破了这两个领域壁垒,并取得不错的成效。

    论文名称: An Image Is Worth 16x16 Words: Transformers For Image Recognition At Scale
    论文链接:https://arxiv.org/abs/2010.11929

    模型结构/算法流程

    Vision Transformer的模型结构相比于Transformer来说更简单,在Transformer模型中,主要包含Encoder和Decoder结构,而ViT(Vision Transformer)仅借鉴了Encoder结构。
    在这里插入图片描述
    ViT的处理流程大致可以分为以下几个步骤:

    1.图片预处理

    预处理这个步骤在论文里并没有详细说明,但是对于ViT这个结构而言,输入的图片尺寸并不是自定义的,ViT-B/16为例,输入的图片尺寸必须为224x224。

    因此,首先需要对输入图片进行尺寸变化,具体方式可以是直接缩放(Resize),也可以进行随机裁剪(RandomResizedCrop),在后面复现的代码中,使用的是随机裁剪的方式。

    2.图片切割

    Transformer需要输入的是一维的Token,对于二维的图像,一种朴素的想法就是把一个个像素点拉平,这样就成了一个一维序列。但是这样造成的一个后果是计算量太庞大,比如一张224x224的图片,变成1维度之后就成了50176,相当于直接输入一篇五万字的文章,模型难以计算。

    那么,一个改进的想法就是把一张图片分成nxn个Patch,每一个Patch作为一个Token,这样计算量就大大减小了。

    以ViT-B/16为例,将输入图片(224x224)按照16x16大小的Patch进行划分,划分后可以得到共 ( 224 / 16 ) 2 = 196 (224/16)^2=196 (224/16)2=196个Patch。每个Patch是三通道的小图片,shape为(16, 16, 3),将其展平就变成了一个长度为768的向量。

    每一个向量作为一个单独的输入,那样我们总共有196个向量,在代码中,可以变成一个[196,768]的矩阵,进行并行输入。

    这一步的操作在论文中是直接采用切割的处理办法,但是在后面的代码实现中,采用了一种更巧妙的解决思路,就是利用一个卷积核大小为16x16,步距为16,卷积核个数为768的卷积层来进行实现。

    再来回顾我们的卷积层计算公式:
    在这里插入图片描述

    输入为[224,244,3],经过卷积层变成[14,14,768],再映射为[196,768]。

    这样,就完成了从图片到Token之间的转换。

    3.添加[class]token

    在上面的结构图中可以看到,输入Encoder的最左侧部分添加了一个0*这个Token,这个就是额外添加的一个[class]token,单独用来处理类别信息,经过Encoder之后,需要单独将这个Token再提取出来,输入到MLP Head之中再输出分类结果。

    这也是为什么结构图中MLP Head的位置是和这个[class]token对齐。

    4.添加位置编码

    在Transformer中,位置编码的作用是为了记忆输入的语序信息。ViT中,同样需要位置编码来记录各图像块之间的位置信息。

    这里主要有两种位置编码思路,一种思路是在转换之前(14,14)的图像块矩阵添加二维(2-D)位置编码,另一种思路是在转换后(196+1)这个维度上添加一维(1-D)位置编码。

    作者也对其做了实验,实验结果如下表所示:

    在这里插入图片描述

    可以看到,添加一维位置编码和二维位置编码并没有太大的差异。作者随后也对一维位置编码的结果进行了可视化,结果如下图所示:

    在这里插入图片描述

    上图中是每一个Patch中各位置的位置编码相似性度量,越接近黄色的位置代表越靠近位置编码的中心位置,可以看到,即使是一维位置编码,同样可以比较好地记录二维信息。

    5.Transformer Encoder

    ViT虽然采用的是Transformer Encoder的结构,但是和Transformer原始的Encoder还是有所区别,我将两者的结构进行对比,如下图所示,左侧为Transformer原始的Encoder结构。
    在这里插入图片描述

    可以看到,大致上两者结构是相同的,主要区别在于Norm层的顺序,原始Transformer的Norm层在多头注意力和前馈网络之后,而ViT将其放到前面,这里的原因,论文里没有做解释。

    关于Norm层,ViT仍是采用Transformer中用到Layer Normalization,计算公式如下:
    在这里插入图片描述

    Norm层之后同样是多头注意力层(Multi-Head Attention),和Transformer中的一样。

    后面的MLP是个单独的结构,论文里没有绘制其详细结构,看到博主太阳花的小绿豆[1]对其进行了绘制,这里转贴在此。MLP也并不复杂,就是两个线性层+GELU激活函数+Dropout的结构,具体细节可以看后面的代码。

    在这里插入图片描述

    6.MLP Head

    在Transformer Encoder输出结果之后,需要再将第一个添加的Class Token提取出来,然后输入到MLP Head进行分类。在论文中,作者先是在ImageNet21K上进行预训练,MLP Head结构由Linear+tanh激活函数+Linear组成,但是迁移到其它数据集训练时,只需要用一个一个Linear即可。

    输出结果之后,再和真实标签做交叉熵损失,这样就可以完成ViT的训练过程。

    效果对比

    在论文中,作者将ViT和之前图像分类领域比较强的ResNet模型进行了对比测试,结果如下:

    在这里插入图片描述

    可以看到,右图中,作者使用了谷歌制作的JFT-300M数据集,当数据量小于30M时,ViT的效果表现不如ResNet,但是当数据量逐渐增大时,ViT才会慢慢超越ResNet。由此可见ViT工作的局限性,它必须要在超大数据集上进行预训练,然后再拿到其它数据集上做迁移学习,才会有好的效果。

    关于ViT模型的不同版本,论文里也做了说明:
    在这里插入图片描述
    其中的Layers就是Transformer Encoder中重复堆叠Encoder Block的次数,Hidden Size就是对应通过Embedding层后每个token的dim(向量的长度),MLP size是Transformer Encoder中MLP Block第一个全连接的节点个数(是Hidden Size的四倍),Heads代表Transformer中Multi-Head Attention的heads数。

    注意力可视化

    ViT这篇论文长达二十多页,里面包含了非常丰富的成果,其中包括注意力可视化。由于作者是首次将Transformer应用到图像领域,里面包含了注意力机制,那么作者就想把注意力得到的结果(也就是Q-K矩阵乘积)换源到图像上,得到结果如下图所示:
    在这里插入图片描述
    可以看到,模型自动学习到了如果注意画面中的分类主体。

    混合模型探索

    在论文的最后,作者又探索了一种混合模型(Hybrid),就是将传统CNN和Transformer进行结合。

    下表中对比了ViT、ResNet和混合模型在不同图像分类数据集上的测试结果,可以看到当Epochs增大时,ResNet和混合模型的效果均不如ViT模型。
    在这里插入图片描述

    实践测试

    下面就来尝试使用ViT做一个简单的分类任务。官方使用的是Tensorflow2框架,下面我使用的是别人利用Pytorch实现的ViT。
    代码来源于https://github.com/WZMIAOMIAO/deep-learning-for-image-processing/tree/master/pytorch_classification/vision_transformer

    实验采用的是花蕊数据集,共5个类别,约4000多个样本。

    数据集下载:https://pan.baidu.com/s/1vpB3s78bV4Xxowfpe07tdw?pwd=8888

    默认使用的是ViT-B/16这个模型,整体结构图[1]如下:
    在这里插入图片描述

    图片预处理

    对数据集和验证集划分之后,需要对数据进行预处理。

    这里对训练集的处理方式是随机切成224x224像素的图片,然后进行水平翻转,再进行归一化和标准化处理;对验证集的处理方式是先Resize成256x256的图片,再从中心位置裁剪成224x224,再进行归一化和标准化处理。

    相关代码:

    data_transform = {
      "train": transforms.Compose([transforms.RandomResizedCrop(224),
                                   transforms.RandomHorizontalFlip(),
                                   transforms.ToTensor(),
                                   transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])]),
      "val": transforms.Compose([transforms.Resize(256),
                                 transforms.CenterCrop(224),
                                 transforms.ToTensor(),
                                 transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])])}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    图片切割

    通过卷积,将图片转成一个个Patch。
    相关代码:

    class PatchEmbed(nn.Module):
        """
        2D Image to Patch Embedding
        """
        def __init__(self, img_size=224, patch_size=16, in_c=3, embed_dim=768, norm_layer=None):
            super().__init__()
            img_size = (img_size, img_size)
            patch_size = (patch_size, patch_size)
            self.img_size = img_size
            self.patch_size = patch_size
            self.grid_size = (img_size[0] // patch_size[0], img_size[1] // patch_size[1])
            self.num_patches = self.grid_size[0] * self.grid_size[1]
    
            self.proj = nn.Conv2d(in_c, embed_dim, kernel_size=patch_size, stride=patch_size)
            self.norm = norm_layer(embed_dim) if norm_layer else nn.Identity()
    
        def forward(self, x):
            B, C, H, W = x.shape
            assert H == self.img_size[0] and W == self.img_size[1], \
                f"Input image size ({H}*{W}) doesn't match model ({self.img_size[0]}*{self.img_size[1]})."
    
            # flatten: [B, C, H, W] -> [B, C, HW]
            # transpose: [B, C, HW] -> [B, HW, C]
            x = self.proj(x).flatten(2).transpose(1, 2)
            x = self.norm(x)
            return x
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    多头注意力

    多头注意力模块实现时注意是通过一个线性层来同时计算qkv三个矩阵,这样可以提升计算效率。

    class Attention(nn.Module):
        def __init__(self,
                     dim,   # 输入token的dim
                     num_heads=8,
                     qkv_bias=False,
                     qk_scale=None,
                     attn_drop_ratio=0.,
                     proj_drop_ratio=0.):
            super(Attention, self).__init__()
            self.num_heads = num_heads
            head_dim = dim // num_heads
            self.scale = qk_scale or head_dim ** -0.5
            self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
            self.attn_drop = nn.Dropout(attn_drop_ratio)
            self.proj = nn.Linear(dim, dim)
            self.proj_drop = nn.Dropout(proj_drop_ratio)
    
        def forward(self, x):
            # [batch_size, num_patches + 1, total_embed_dim]
            B, N, C = x.shape
    
            # qkv(): -> [batch_size, num_patches + 1, 3 * total_embed_dim]
            # reshape: -> [batch_size, num_patches + 1, 3, num_heads, embed_dim_per_head]
            # permute: -> [3, batch_size, num_heads, num_patches + 1, embed_dim_per_head]
            qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4)
            # [batch_size, num_heads, num_patches + 1, embed_dim_per_head]
            q, k, v = qkv[0], qkv[1], qkv[2]  # make torchscript happy (cannot use tensor as tuple)
    
            # transpose: -> [batch_size, num_heads, embed_dim_per_head, num_patches + 1]
            # @: multiply -> [batch_size, num_heads, num_patches + 1, num_patches + 1]
            attn = (q @ k.transpose(-2, -1)) * self.scale
            attn = attn.softmax(dim=-1)
            attn = self.attn_drop(attn)
    
            # @: multiply -> [batch_size, num_heads, num_patches + 1, embed_dim_per_head]
            # transpose: -> [batch_size, num_patches + 1, num_heads, embed_dim_per_head]
            # reshape: -> [batch_size, num_patches + 1, total_embed_dim]
            x = (attn @ v).transpose(1, 2).reshape(B, N, C)
            x = self.proj(x)
            x = self.proj_drop(x)
            return x
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    MLP Block

    class Mlp(nn.Module):
        """
        MLP as used in Vision Transformer, MLP-Mixer and related networks
        """
        def __init__(self, in_features, hidden_features=None, out_features=None, act_layer=nn.GELU, drop=0.):
            super().__init__()
            out_features = out_features or in_features
            hidden_features = hidden_features or in_features
            self.fc1 = nn.Linear(in_features, hidden_features)
            self.act = act_layer()
            self.fc2 = nn.Linear(hidden_features, out_features)
            self.drop = nn.Dropout(drop)
    
        def forward(self, x):
            x = self.fc1(x)
            x = self.act(x)
            x = self.drop(x)
            x = self.fc2(x)
            x = self.drop(x)
            return x
    
    
    class Block(nn.Module):
        def __init__(self,
                     dim,
                     num_heads,
                     mlp_ratio=4.,
                     qkv_bias=False,
                     qk_scale=None,
                     drop_ratio=0.,
                     attn_drop_ratio=0.,
                     drop_path_ratio=0.,
                     act_layer=nn.GELU,
                     norm_layer=nn.LayerNorm):
            super(Block, self).__init__()
            self.norm1 = norm_layer(dim)
            self.attn = Attention(dim, num_heads=num_heads, qkv_bias=qkv_bias, qk_scale=qk_scale,
                                  attn_drop_ratio=attn_drop_ratio, proj_drop_ratio=drop_ratio)
            # NOTE: drop path for stochastic depth, we shall see if this is better than dropout here
            self.drop_path = DropPath(drop_path_ratio) if drop_path_ratio > 0. else nn.Identity()
            self.norm2 = norm_layer(dim)
            mlp_hidden_dim = int(dim * mlp_ratio)
            self.mlp = Mlp(in_features=dim, hidden_features=mlp_hidden_dim, act_layer=act_layer, drop=drop_ratio)
    
        def forward(self, x):
            x = x + self.drop_path(self.attn(self.norm1(x)))
            x = x + self.drop_path(self.mlp(self.norm2(x)))
            return x
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    VisionTransformer

    将上面各部分结合起来,得到VisionTransformer整体结构,可以和上面的结构图对比

    class VisionTransformer(nn.Module):
        def __init__(self, img_size=224, patch_size=16, in_c=3, num_classes=1000,
                     embed_dim=768, depth=12, num_heads=12, mlp_ratio=4.0, qkv_bias=True,
                     qk_scale=None, representation_size=None, distilled=False, drop_ratio=0.,
                     attn_drop_ratio=0., drop_path_ratio=0., embed_layer=PatchEmbed, norm_layer=None,
                     act_layer=None):
            """
            Args:
                img_size (int, tuple): input image size
                patch_size (int, tuple): patch size
                in_c (int): number of input channels
                num_classes (int): number of classes for classification head
                embed_dim (int): embedding dimension
                depth (int): depth of transformer
                num_heads (int): number of attention heads
                mlp_ratio (int): ratio of mlp hidden dim to embedding dim
                qkv_bias (bool): enable bias for qkv if True
                qk_scale (float): override default qk scale of head_dim ** -0.5 if set
                representation_size (Optional[int]): enable and set representation layer (pre-logits) to this value if set
                distilled (bool): model includes a distillation token and head as in DeiT models
                drop_ratio (float): dropout rate
                attn_drop_ratio (float): attention dropout rate
                drop_path_ratio (float): stochastic depth rate
                embed_layer (nn.Module): patch embedding layer
                norm_layer: (nn.Module): normalization layer
            """
            super(VisionTransformer, self).__init__()
            self.num_classes = num_classes
            self.num_features = self.embed_dim = embed_dim  # num_features for consistency with other models
            self.num_tokens = 2 if distilled else 1
            norm_layer = norm_layer or partial(nn.LayerNorm, eps=1e-6)
            act_layer = act_layer or nn.GELU
    
            self.patch_embed = embed_layer(img_size=img_size, patch_size=patch_size, in_c=in_c, embed_dim=embed_dim)
            num_patches = self.patch_embed.num_patches
    
            self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
            self.dist_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) if distilled else None
            self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + self.num_tokens, embed_dim))
            self.pos_drop = nn.Dropout(p=drop_ratio)
    
            dpr = [x.item() for x in torch.linspace(0, drop_path_ratio, depth)]  # stochastic depth decay rule
            self.blocks = nn.Sequential(*[
                Block(dim=embed_dim, num_heads=num_heads, mlp_ratio=mlp_ratio, qkv_bias=qkv_bias, qk_scale=qk_scale,
                      drop_ratio=drop_ratio, attn_drop_ratio=attn_drop_ratio, drop_path_ratio=dpr[i],
                      norm_layer=norm_layer, act_layer=act_layer)
                for i in range(depth)
            ])
            self.norm = norm_layer(embed_dim)
    
            # Representation layer
            if representation_size and not distilled:
                self.has_logits = True
                self.num_features = representation_size
                self.pre_logits = nn.Sequential(OrderedDict([
                    ("fc", nn.Linear(embed_dim, representation_size)),
                    ("act", nn.Tanh())
                ]))
            else:
                self.has_logits = False
                self.pre_logits = nn.Identity()
    
            # Classifier head(s)
            self.head = nn.Linear(self.num_features, num_classes) if num_classes > 0 else nn.Identity()
            self.head_dist = None
            if distilled:
                self.head_dist = nn.Linear(self.embed_dim, self.num_classes) if num_classes > 0 else nn.Identity()
    
            # Weight init
            nn.init.trunc_normal_(self.pos_embed, std=0.02)
            if self.dist_token is not None:
                nn.init.trunc_normal_(self.dist_token, std=0.02)
    
            nn.init.trunc_normal_(self.cls_token, std=0.02)
            self.apply(_init_vit_weights)
    
        def forward_features(self, x):
            # [B, C, H, W] -> [B, num_patches, embed_dim]
            x = self.patch_embed(x)  # [B, 196, 768]
            # [1, 1, 768] -> [B, 1, 768]
            cls_token = self.cls_token.expand(x.shape[0], -1, -1)
            if self.dist_token is None:
                x = torch.cat((cls_token, x), dim=1)  # [B, 197, 768]
            else:
                x = torch.cat((cls_token, self.dist_token.expand(x.shape[0], -1, -1), x), dim=1)
    
            x = self.pos_drop(x + self.pos_embed)
            x = self.blocks(x)
            x = self.norm(x)
            if self.dist_token is None:
                return self.pre_logits(x[:, 0])
            else:
                return x[:, 0], x[:, 1]
    
        def forward(self, x):
            x = self.forward_features(x)
            if self.head_dist is not None:
                x, x_dist = self.head(x[0]), self.head_dist(x[1])
                if self.training and not torch.jit.is_scripting():
                    # during inference, return the average of both classifier predictions
                    return x, x_dist
                else:
                    return (x + x_dist) / 2
            else:
                x = self.head(x)
            return x
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106

    我使用vit_base_patch16_224_in21k这个模型,训练了10个epoch,验证集上准确率达到了98.5%。
    整体模型还是比较大的,预训练权重大小为393MB,但是训练速度还是挺快的,因为在代码中有个冻结权重的操作,主干部分全部冻结,仅训练分类头。

    代码备份

    代码备份在此:https://pan.baidu.com/s/10vBdFFtGNPSuSvxXTJij4A?pwd=8888

    References

    [1]https://blog.csdn.net/qq_37541097/article/details/118242600
    [2]https://www.bilibili.com/video/BV15P4y137jb

  • 相关阅读:
    【Spring】开发框架Spring核心技术含Resource接口详细讲解
    QT实验之闪烁灯
    容器基础--基本概念入门
    Ubuntu20.04安装k8s v1.21.0
    Java_移位运算简述
    【ChatGPT】ChatGPT发展历史
    Android系统_MSM8953_android10_集成openssh
    可能是2022最详细的结构体讲解
    [运维工具]ubuntu下迁移home目录至新的分区教程详解
    ios开发错误积累
  • 原文地址:https://blog.csdn.net/qq1198768105/article/details/126162573