前言
近期参与到了手写AI的车道线检测的学习中去,以此系列笔记记录学习与思考的全过程。车道线检测系列会持续更新,力求完整精炼,引人启示。所需前期知识,可以结合手写AI进行系统的学习。
SE简单实现
class SELayer(nn.Module):
def __init__(self, channel, reduction=16):
super(SELayer, self).__init__()
self.avg_pool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Sequential(
nn.Linear(channel, channel // reduction, bias=False),
nn.ReLU(inplace=True),
nn.Linear(channel // reduction, channel, bias=False),
nn.Sigmoid()
)
def forward(self, x):
b, c, _, _ = x.size()
y = self.avg_pool(x).view(b, c)
y = self.fc(y).view(b, c, 1, 1)
return x * y.expand_as(x)
【精选】深入理解CV中的Attention机制之SE模块_se模块的作用_草莓酱土司的博客-CSDN博客
CV领域常用的注意力机制模块(SE、CBAM)_se机制-CSDN博客
SE模块详解_se层-CSDN博客
Q,K,V简单理解
QKV的含义和注意力机制的三个计算步骤:Q和所有K计算相似性;对相似性采用softmax转化为概率分布;将概率分布和V进行一一对应相乘,最后相加得到新的和Q一样长的向量输出
# 假设q是(1,N,512),N就是最大标签化后的list长度,k是(1,M,512),M可以等于N,也可以不相等
# (1,N,512) x (1,512,M)-->(1,N,M)
# 是一个包含了N个问题的批次,每个问题都用一个512维的向量来表示。
# M代表关键词
# 算每个问题和所有关键词之间的关系
attn = torch.matmul(q, k.transpose(2, 3))
# softmax转化为概率,输出(1,N,M),表示q中每个n和每个m的相关性
# 每一行的数值都在0和1之间,并且加起来等于1
attn=F.softmax(attn, dim=-1)
# (1,N,M) x (1,M,512)-->(1,N,512),V和k的shape相同
output = torch.matmul(attn, v)
好的,这段代码是一个简化的注意力机制计算过程的表示,通常用于神经网络中的自注意力机制,特别是在Transformer架构中。在这里,我们使用三个关键变量:
q
(查询),k
(键),和v
(值)。这个计算过程是注意力机制中的核心,其目的是为了让模型能够关注到输入数据中最重要的部分。下面我会逐步解释这段代码,结合一个具体的例子来进行说明:
q
(查询): 假设q
代表你想要搜索的一组问题。在这个张量中,N
代表你有多少个问题,而512
是每个问题的特征或者编码的维度。所以(1, N, 512)
可以看作是一个包含了N个问题的批次,每个问题都用一个512维的向量来表示。k
(键): 假设k
代表一组关键词。M
可以和N
相同,也可以不同,代表你有多少个关键词。每个关键词也用一个512维的向量来表示。attn = torch.matmul(q, k.transpose(2, 3))
: 这一步是计算每个问题和所有关键词之间的关系。首先,你需要将k
转置(即k.transpose(2, 3)
),这样原来的(1, M, 512)
变成(1, 512, M)
。然后,你将q
和转置后的k
进行矩阵乘法,结果是一个新的三维张量(1, N, M)
,它代表了每一个问题与每一个关键词之间的关联度。attn=F.softmax(attn, dim=-1)
: 这步使用softmax函数将刚才得到的关联度数值转换成概率值,这样每一行的数值都在0和1之间,并且加起来等于1。这个概率值代表了当考虑问题时,每个关键词的重要性。output = torch.matmul(attn, v)
: 最后,你用得到的注意力概率矩阵attn
和v
(值)进行矩阵乘法。这里v
通常和k
有相同的形状(1, M, 512)
,代表了关键词的值。乘法的结果是(1, N, 512)
,这表示每个问题根据其与关键词的关联度得到的加权值的总和。举个例子:
- 假设
N
是3,代表有三个问题。M
是4,代表有四个关键词。q
可能表示一个含有三个问题的批次,其中每个问题由一个512维的向量表示。k
和v
分别表示四个关键词的特征和相应的信息。经过上述计算,模型会输出一个新的表示每个问题的张量,这个张量考虑了每个问题与每个关键词的关联度,相当于对问题进行了重新编码,使得编码后的问题更侧重于与之高度相关的关键词。在实际应用中,这有助于神经网络模型更好地处理和理解自然语言数据。
好的,让我们通过一个通俗的例子来理解带有注意力机制的编码器-解码器模型的工作原理。我们可以把这个过程想象成一场足球比赛的解说:
在没有注意力机制的情况下,解说员(解码器)只关注最后一次传球(编码器的最后一个隐含输出)来预测接下来的射门。他没有考虑比赛中的其他传球动作。这就像是有一个固定的思路去解说比赛,不管场上发生了什么。
引入了注意力机制后,情况就变得不同了。现在解说员在解说即将发生的射门之前,会观察场上所有球员的移动(编码器的每一个时间步的隐态输出,也就是一系列的Key和Value)。他会评估哪些传球(Key)对当前的进攻(查询,也就是Q)最为关键。这个评估就像是给每个传球打一个分数(点乘计算相似性得到的分数),然后通过一种专门的算法(softmax)将这些分数转化成一个概率分布,代表每个传球对即将射门的重要性。
比如,解说员可能注意到,尽管最后一次传球很关键,但是比赛的第一个角球传中实际上给了攻方一个很好的机会,而且边路的突破非常具有威胁性。他会根据每个动作的重要性给它们加权,最终形成一个“上下文向量”,这个向量包含了所有他认为重要的信息。这就是加权求和的过程。
最后,这个上下文向量就成为了解说员描述即将发生的射门的基础。他不再只是简单地重复最后一次传球的情况,而是结合了整个比赛的发展脉络来进行解说。在解码阶段,他会使用这个信息丰富的上下文来生成更为准确和生动的解说文本。
同样的,在神经网络中,解码器在生成每一个输出(比如翻译任务中的每一个单词)时,都会考虑编码器阶段的全部信息,并且每一步都可能关注(通过注意力分数)不同的输入部分,生成更为准确和合适的输出。
标准transformer流程
码器输入数据处理->编码器运行->解码器输入数据处理->解码器运行->分类head
编码器输入数据处理
编码器输入数据处理
可以简单统计所有训练句子的单词个数,取最大即可,假设统计后发现待翻译句子最长是10个单词,那么编码器输入是10x512,额外填充的512维向量可以采用固定的标志编码得到,例如$$。
位置编码positional encoding
在词向量中加入了单词的位置信息。原因是sin和cos的如下特性。
假设k=1,那么下一个位置的编码向量可以由前面的编码向量线性表示,等价于以一种非常容易学会的方式告诉了网络单词之间的绝对位置,让模型能够轻松学习到相对位置信息。 注意编码方式不是唯一的,将单词嵌入向量和位置编码向量相加就可以得到编码器的真正输入了,其输出shape是(b,N,512)。
编码器前向过程
编码器由两部分组成:自注意力层和前馈神经网络层。
注意上图没有绘制出单词嵌入向量和位置编码向量相加过程,但是是存在的。
自注意力层
前馈神经网络层
import torch.nn as nn
import torch.nn.functional as F
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_in, d_hid, dropout=0.1):
super().__init__()
self.w_1 = nn.Linear(d_in, d_hid)
self.w_2 = nn.Linear(d_hid, d_in)
self.layer_norm = nn.LayerNorm(d_in, eps=1e-6)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
residual = x
x = self.w_2(F.relu(self.w_1(x)))
x = self.dropout(x)
x += residual
return self.layer_norm(x)
编码层操作整体流程
解码器输入数据处理
和编码器单词嵌入不同的地方是在进行目标单词嵌入前,还需要将目标单词即是i am a student右移动一位,新增加的一个位置采用提前定义好的标志位BOS_WORD代替,现在就变成[BOS_WORD,i,am,a,student],为啥要右移?因为解码过程和seq2seq一样是顺序解码的,需要提供一个开始解码标志,。不然第一个时间步的解码单词i是如何输出的呢?具体解码过程其实是:输入BOS_WORD,解码器输出i;输入前面已经解码的BOS_WORD和i,解码器输出am…,输入已经解码的BOS_WORD、i、am、a和student,解码器输出解码结束标志位EOS_WORD,每次解码都会利用前面已经解码输出的所有单词嵌入信息
解码器前向过程
解码器结构,其包括:带有mask的MultiHeadAttention、MultiHeadAttention和前馈神经网络层三个组件,带有mask的MultiHeadAttention和MultiHeadAttention结构和代码写法是完全相同,唯一区别是是否输入了mask。
为啥要mask?原因依然是顺序解码导致的。试想模型训练好了,开始进行翻译(测试),其流程就是上面写的:输入BOS_WORD,解码器输出i;输入前面已经解码的BOS_WORD和i,解码器输出am…,输入已经解码的BOS_WORD、i、am、a和student,解码器输出解码结束标志位EOS_WORD,每次解码都会利用前面已经解码输出的所有单词嵌入信息,这个测试过程是没有问题,但是训练时候我肯定不想采用上述顺序解码类似rnn即一个一个目标单词嵌入向量顺序输入训练,肯定想采用类似编码器中的矩阵并行算法,一步就把所有目标单词预测出来。要实现这个功能就可以参考编码器的操作,把目标单词嵌入向量组成矩阵一次输入即可,但是在解码am时候,不能利用到后面单词a和student的目标单词嵌入向量信息,否则这就是作弊(测试时候不可能能未卜先知)。为此引入mask,目的是构成下三角矩阵,右上角全部设置为负无穷(相当于忽略),从而实现当解码第一个字的时候,第一个字只能与第一个字计算相关性,当解出第二个字的时候,只能计算出第二个字与第一个字和第二个字的相关性。具体是:在解码器中,自注意力层只被允许处理输出序列中更靠前的那些位置,在softmax步骤前,它会把后面的位置给隐去(把它们设为-inf)。
还有个非常重要点需要知道(看图示可以发现):解码器内部的带有mask的MultiHeadAttention的qkv向量输入来自目标单词嵌入或者前一个解码器输出,三者是相同的,但是后面的MultiHeadAttention的qkv向量中的kv来自最后一层编码器的输入,而q来自带有mask的MultiHeadAttention模块的输出。
import torch.nn as nn
class DecoderLayer(nn.Module):
''' Compose with three layers '''
def __init__(self, d_model, d_inner, n_head, d_k, d_v, dropout=0.1):
super(DecoderLayer, self).__init__()
self.slf_attn = MultiHeadAttention(n_head, d_model, d_k, d_v, dropout=dropout)
self.enc_attn = MultiHeadAttention(n_head, d_model, d_k, d_v, dropout=dropout)
self.pos_ffn = PositionwiseFeedForward(d_model, d_inner, dropout=dropout)
def forward(
self, dec_input, enc_output,
slf_attn_mask=None, dec_enc_attn_mask=None):
# 自注意力层 Self Attention Layer
dec_output, dec_slf_attn = self.slf_attn(
dec_input, dec_input, dec_input, mask=slf_attn_mask)
# 编码器-解码器注意力层 Encoder-Decoder Attention Layer
dec_output, dec_enc_attn = self.enc_attn(
dec_output, enc_output, enc_output, mask=dec_enc_attn_mask)
# 前馈全连接层 Position-wise Feed-Forward Network
dec_output = self.pos_ffn(dec_output)
return dec_output, dec_slf_attn, dec_enc_attn
class Decoder(nn.Module):
def __init__(
self, n_trg_vocab, d_word_vec, n_layers, n_head, d_k, d_v,
d_model, d_inner, pad_idx, n_position=200, dropout=0.1):
super(Decoder, self).__init__()
# 目标单词嵌入
self.trg_word_emb = nn.Embedding(n_trg_vocab, d_word_vec, padding_idx=pad_idx)
# 位置嵌入向量
self.position_enc = PositionalEncoding(d_word_vec, n_position=n_position)
self.dropout = nn.Dropout(p=dropout)
# n个解码器
self.layer_stack = nn.ModuleList([
DecoderLayer(d_model, d_inner, n_head, d_k, d_v, dropout=dropout)
for _ in range(n_layers)])
# 层归一化
self.layer_norm = nn.LayerNorm(d_model, eps=1e-6)
def forward(self, trg_seq, trg_mask, enc_output, src_mask, return_attns=False):
# 目标单词嵌入+位置编码
dec_output = self.dropout(self.position_enc(self.trg_word_emb(trg_seq)))
dec_output = self.layer_norm(dec_output)
# 遍历每个解码器
for dec_layer in self.layer_stack:
# 需要输入3个信息:目标单词嵌入+位置编码、最后一个编码器输出enc_output
# 和dec_enc_attn_mask,解码时候不能看到未来单词信息
dec_output, dec_slf_attn, dec_enc_attn = dec_layer(
dec_output, enc_output, slf_attn_mask=trg_mask, dec_enc_attn_mask=src_mask)
# 如果需要返回注意力权重,可以在这里处理
if return_attns:
# 实现返回注意力权重的逻辑(示例)
# 这里只是一个示例,实际实现应该依赖于具体需求
return dec_output, dec_slf_attn, dec_enc_attn
return dec_output
分类层
在进行编码器-解码器后输出依然是向量,需要在后面接fc+softmax层进行分类训练。假设当前训练过程是翻译任务需要输出i am a student EOS_WORD这5个单词。假设我们的模型是从训练集中学习一万个不同的英语单词(我们模型的“输出词表”)。因此softmax后输出为一万个单元格长度的向量,每个单元格对应某一个单词的分数,这其实就是普通多分类问题,只不过维度比较大而已。
依然以前面例子为例,假设编码器输出shape是(b,100,512),经过fc后变成(b,100,10000),然后对最后一个维度进行softmax操作,得到bx100个单词的概率分布,在训练过程中bx100个单词是知道label的,故可以直接采用ce loss进行训练。
self.trg_word_prj = nn.Linear(d_model, n_trg_vocab, bias=False)
dec_output, *_ = self.model.decoder(trg_seq, trg_mask, enc_output, src_mask)
return F.softmax(self.model.trg_word_prj(dec_output), dim=-1)
前向流程
以翻译任务为例:
- 将源单词进行嵌入,组成矩阵(加上位置编码矩阵)输入到n个编码器中,输出编码向量KV
- 第一个解码器先输入一个BOS_WORD单词嵌入向量,后续解码器接受该解码器输出,结合KV进行第一次解码
- 将第一次解码单词进行嵌入,联合BOS_WORD单词嵌入向量构成矩阵再次输入到解码器中进行第二次解码,得到解码单词
- 不断循环,每次的第一个解码器输入都不同,其包含了前面时间步长解码出的所有单词
- 直到输出EOS_WORD表示解码结束或者强制设置最大时间步长即可
详细说明下循环解码过程:第一次解码,输入BOS_WORD单词嵌入向量,假设是(1,256),而编码器输出始终不变是(100,256),那么第一次解码过程是(1,256)+位置编码作为解码器输入,解码输出是(1,256),经过fc层(参数shape是(256,10000))变成(1,10000),10000是单词总数,此时就可以解码得到第一个单词i;接着将BOS_WORD和i都进行嵌入,得到(2,256)输入,同样运行,输出是(2,256),经过fc是(2,10000),此时不需要第一个维度输出只需要[-1,10000]既可以解码第二个单词,后面就一直迭代直到输出结束解码标注。
这个解码过程其实就是标准的seq2seq流程。到目前为止就描述完了整个标准transformer训练和测试流程。
为什么Transformer 需要进行 Multi-head Attention? - 知乎
代码详解——Transformer_transformer代码解读-CSDN博客
【精选】Transformer流程解析及细节思考-CSDN博客
详解注意力机制和Transformer_注意力机制与transformer_zyw2002的博客-CSDN博客
详解可变形注意力模块(Deformable Attention Module)_attention模块-CSDN博客
视觉领域的transformer
分类vision transformer
论文题目:An Image is Worth 16x16 Words:Transformers for Image Recognition at Scale
论文地址:https://arxiv.org/abs/2010.11929
github: https://github.com/lucidrains/vit-pytorch
其做法超级简单,只含有编码器模块:
本文出发点是彻底抛弃CNN,以前的cv领域虽然引入transformer,但是或多或少都用到了cnn或者rnn,本文就比较纯粹了,整个算法几句话就说清楚了,下面直接分析。
图片分块和降维
因为transformer的输入需要序列,所以最简单做法就是把图片切分为patch,然后拉成序列即可。 假设输入图片大小是256x256,打算分成64个patch,每个patch是32x32像素
x = rearrange(img, 'b c (h p1) (w p2) -> b (h w) (p1 p2 c)', p1=p, p2=p)
这个写法是采用了爱因斯坦表达式,具体是采用了einops库实现,内部集成了各种算子,rearrange就是其中一个,非常高效。不懂这种语法的请自行百度。p就是patch大小,假设输入是b,3,256,256,则rearrange操作是先变成(b,3,8x32,8x32),最后变成(b,8x8,32x32x3)即(b,64,3072),将每张图片切分成64个小块,每个小块长度是32x32x3=3072,也就是说输入长度为64的图像序列,每个元素采用3072长度进行编码。
考虑到3072有点大,故作者先进行降维:
# 将3072变成dim,假设是1024
self.patch_to_embedding = nn.Linear(patch_dim, dim)
x = self.patch_to_embedding(x)
细看论文上图,可以发现假设切成9个块,但是最终到transfomer输入是10个向量,额外追加了一个0和*。为啥要追加?原因是我们现在没有解码器了,而是编码后直接就进行分类预测,那么该编码器就要负责一点点解码器功能,那就是:需要一个类似开启解码标志,*非常类似于标准transformer解码器中输入的目标嵌入向量右移一位操作。
位置编码
位置编码也是必不可少的,长度应该是1024,这里做的比较简单,没有采用sincos编码,而是直接设置为可学习,效果差不多
# num_patches=64,dim=1024,+1是因为多了一个cls开启解码标志
self.pos_embedding = nn.Parameter(torch.randn(1, num_patches + 1, dim))
对训练好的pos_embedding进行可视化,如下所示:
将patch嵌入向量和位置编码向量相加即可作为编码器输入
x += self.pos_embedding[:, :(n + 1)]
x = self.dropout(x)
编码器前向过程
作者采用的是没有任何改动的transformer,故没有啥说的。
self.transformer = Transformer(dim, depth, heads, mlp_dim, dropout)
假设输入是(b,65,1024),那么transformer输出也是(b,65,1024)
分类head
在编码器后接fc分类器head即可
整体代码
import torch
from torch import nn
from einops import rearrange, repeat
class ViT(nn.Module):
def __init__(self, *, image_size, patch_size, num_classes, dim, depth, heads, mlp_dim, channels=3, dropout=0., emb_dropout=0.):
super().__init__()
# 计算一张图像会被分成多少个patch
num_patches = (image_size // patch_size) ** 2
# 每个patch的维度
patch_dim = channels * patch_size ** 2
# 存储patch大小
self.patch_size = patch_size
# 位置编码的参数,随机初始化
self.pos_embedding = nn.Parameter(torch.randn(1, num_patches + 1, dim))
# 将patch转换到指定维度的线性层
self.patch_to_embedding = nn.Linear(patch_dim, dim)
# 用于分类的token
self.cls_token = nn.Parameter(torch.randn(1, 1, dim))
# Dropout层
self.dropout = nn.Dropout(emb_dropout)
# Transformer模型
self.transformer = Transformer(dim, depth, heads, mlp_dim, dropout)
# MLP头,用于分类任务
self.mlp_head = nn.Sequential(
nn.LayerNorm(dim), # 归一化层
nn.Linear(dim, num_classes) # 线性层到最终的类别数
)
def forward(self, img, mask=None):
# 获取patch的大小
p = self.patch_size
# 把图片重组为patch表示,然后通过一个线性层进行embedding
x = rearrange(img, 'b c (h p1) (w p2) -> b (h w) (p1 p2 c)', p1=p, p2=p)
# 将patch映射到指定维度
x = self.patch_to_embedding(x)
# 获得batch size和序列长度
b, n, _ = x.shape
# 重复cls_token以匹配batch size
cls_tokens = repeat(self.cls_token, '() n d -> b n d', b=b)
# 将cls_token附加到patch embedding的前面
x = torch.cat((cls_tokens, x), dim=1)
# 加上位置编码
x += self.pos_embedding[:, :(n + 1)]
# 应用Dropout
x = self.dropout(x)
# 通过Transformer层
x = self.transformer(x, mask)
# 只取分类token的输出
x = x[:, 0]
# 通过分类头得到最终的类别预测
return self.mlp_head(x)
实验分析
这个表格显示了不同的深度学习模型在多个数据集上的性能结果,以及它们训练时的一些特性和计算开销。这里主要比较了不同版本的Vision Transformer (ViT) 和 ResNet,在ImageNet、ImageNet ReaL、CIFAR-10、CIFAR-100、Pets和Flowers数据集上的表现。另外,表中还包含了模型的训练时长(Epochs)和计算开销(exaFLOPs)。让我们来分析表格的内容:
模型(Model):
- ViT-B/32, ViT-B/16, 等:这些是Vision Transformer模型的不同配置,数字代表不同的patch大小,字母B和L分别代表模型的大小(Base或Large)。
- ResNet50x1, ResNet101x1, 等:这些是不同配置的ResNet模型,数字表示模型中的层的数量,x1、x2等代表模型宽度的放大因子。
- R50x1 + ViT-B/32, 等:这些表示结合了ResNet和ViT模型的混合体。
训练时长(Epochs):
- 这一列显示了每个模型在指定数据集上训练的总周期数。Epochs较少可能意味着训练时间短,但可能需要更高的计算效率。
数据集性能:
- ImageNet, ImageNet ReaL, CIFAR-10, CIFAR-100, Pets, Flowers:这些列显示了在相应数据集上的分类准确率。例如,在ImageNet数据集上,ViT-L/16在14个epochs后达到了88.12%的准确率。
- 在大多数情况下,ViT模型在较少的epochs下能达到相当高的准确率,特别是L版本(Large),表现出较强的性能。
- 结合了ResNet和ViT的模型在多个数据集上也展现出了强劲的性能,例如R50x1 + ViT-L/16在ImageNet上有87.12%的准确率。
计算开销(exaFLOPs):
- 这一列显示了模型训练的总浮点运算数,用exaFLOPs(百万亿次浮点运算)表示。ViT模型通常计算开销更大,特别是L版本。
性能总结:
- ViT模型(特别是Large版本)在各种任务上的性能通常优于传统的ResNet模型,但计算开销也相应更高。
- 模型的宽度和深度对性能有明显的影响,增加深度(如ResNet200x3)通常会提升性能,但也大幅增加计算开销。
- 结合ResNet和ViT的混合模型通常能获得较好的性能-计算开销平衡,比如R50x1 + ViT-B/32在多个数据集上都展现出了竞争力强的性能,并且计算开销适中。
总的来说,Vision Transformer在多个数据集上表现出色,尽管它们的计算需求较高。此外,ViT和ResNet的结合使用,可能提供了一种平衡性能和计算资源的有效途径。这些结果可能有助于研究人员和工程师选择合适的模型架构,针对他们特定的应用和计算资源限制。
同时应用transformer,一个突出优点是可解释性比较强:
目标检测detr
论文名称:End-to-End Object Detection with Transformers
论文地址:https://arxiv.org/abs/2005.12872
github:https://github.com/facebookresearch/detr
detr是facebook提出的引入transformer到目标检测领域的算法,效果很好,做法也很简单,符合其一贯的简洁优雅设计做法。
对于目标检测任务,其要求输出给定图片中所有前景物体的类别和bbox坐标,该任务实际上是无序集合预测问题。针对该问题,d**etr做法非常简单:给定一张图片,经过CNN进行特征提取,然后变成特征序列输入到transformer的编解码器中,直接输出指定长度为N的无序集合,集合中每个元素包含物体类别和坐标。**其中N表示整个数据集中图片上最多物体的数目,因为整个训练和测试都Batch进行,如果不设置最大输出集合数,无法进行batch训练,如果图片中物体不够N个,那么就采用no object填充,表示该元素是背景。
整个思想看起来非常简单,相比faster rcnn或者yolo算法那就简单太多了,因为其不需要设置先验anchor,超参几乎没有,也不需要nms(因为输出的无序集合没有重复情况),并且在代码程度相比faster rcnn那就不知道简单多少倍了,通过简单修改就可以应用于全景分割任务。
detr核心思想分析
相比faster rcnn等做法,detr最大特点是将目标检测问题转化为无序集合预测问题。论文中特意指出faster rcnn这种设置一大堆anchor,然后基于anchor进行分类和回归其实属于代理做法即不是最直接做法,目标检测任务就是输出无序集合,而faster rcnn等算法通过各种操作,并结合复杂后处理最终才得到无序集合属于绕路了,而detr就比较纯粹了。
尽管将transformer引入目标检测领域可以避免上述各种问题,但是其依然存在两个核心操作:
- 无序集合输出的loss计算
- 针对目标检测的transformer改进
detr算法实现细节
下面结合代码和原理对其核心环节进行深入分析。
无序集合输出的loss计算
# detr分类输出,num_queries=100,shape是(b,100,92)
bs, num_queries = outputs["pred_logits"].shape[:2]
# 得到概率输出(bx100,92)
out_prob = outputs["pred_logits"].flatten(0, 1).softmax(-1)
# 得到bbox分支输出(bx100,4)
out_bbox = outputs["pred_boxes"].flatten(0, 1)
# 准备分类target shape=(m,)里面存储的是类别索引,m包括了整个batch内部的所有gt bbox
tgt_ids = torch.cat([v["labels"] for v in targets])
# 准备bbox target shape=(m,4),已经归一化了
tgt_bbox = torch.cat([v["boxes"] for v in targets])
# 核心
# L1 距离确保预测边界框在每个坐标轴上与真实边界框接近,而 GIOU 确保整体的形状和相对位置是合理的。
# 在实际操作中,这种多损失组合方法可以帮助算法更好地学习到不同方面的边界框属性,提高模型的泛化能力和准确性。
# bx100,92->bx100,m,对于每个预测结果,把目前gt里面有的所有类别值提取出来,其余值不需要参与匹配
cost_class = -out_prob[:, tgt_ids]
# 计算out_bbox和tgt_bbox两两之间的l1距离 bx100,m
cost_bbox = torch.cdist(out_bbox, tgt_bbox, p=1)
# 额外多计算一个giou loss bx100,m
cost_giou = -generalized_box_iou(box_cxcywh_to_xyxy(out_bbox), box_cxcywh_to_xyxy(tgt_bbox))
# 得到最终的广义距离bx100,m,距离越小越可能是最优匹配
C = self.cost_bbox * cost_bbox + self.cost_class * cost_class + self.cost_giou * cost_giou
# bx100,m--> batch,100,m
C = C.view(bs, num_queries, -1).cpu()
# 计算每个batch内部有多少物体,后续计算时候按照单张图片进行匹配,没必要batch级别匹配,徒增计算
sizes = [len(v["boxes"]) for v in targets]
# 匈牙利最优匹配,返回匹配索引
indices = [linear_sum_assignment(c[i]) for i, c in enumerate(C.split(sizes, -1))]
# 返回匹配的索引对
return [(torch.as_tensor(i, dtype=torch.int64), torch.as_tensor(j, dtype=torch.int64)) for i, j in indices]
在得到匹配关系后算loss就水到渠成了。分类分支计算ce loss,bbox分支计算l1 loss+giou loss:
def loss_labels(self, outputs, targets, indices, num_boxes, log=True):
# `outputs['pred_logits']`包含模型输出的原始类别预测
# 形状是(batch_size, num_queries, num_classes)
src_logits = outputs['pred_logits']
# `_get_src_permutation_idx`函数用于在匹配过程后获取预测标签和目标标签对齐的索引
idx = self._get_src_permutation_idx(indices)
# 根据匹配索引,汇总所有真实的类别标签
target_classes_o = torch.cat([t["labels"][J] for t, (_, J) in zip(targets, indices)])
# 创建一个形状为(batch_size, num_queries)的张量,其填充的是'无对象'类别的索引
# (通常是实际对象类别数加一,表示背景)
target_classes = torch.full(src_logits.shape[:2], self.num_classes,
dtype=torch.int64, device=src_logits.device)
# 将idx索引处的target_classes更新为实际的目标类别标签
target_classes[idx] = target_classes_o
# 计算交叉熵损失,self.empty_weight为前景和背景的权重,分别设为1和0.1,用于克服类别不平衡问题
loss_ce = F.cross_entropy(src_logits.transpose(1, 2), target_classes, self.empty_weight)
losses = {'loss_ce': loss_ce} # 将交叉熵损失加入到losses字典中
return losses
def loss_boxes(self, outputs, targets, indices, num_boxes):
# 使用匹配索引来获取预测边界框和目标边界框对齐的索引
idx = self._get_src_permutation_idx(indices)
# 选择对应的预测边界框
src_boxes = outputs['pred_boxes'][idx]
# 汇总所有真实的边界框目标
target_boxes = torch.cat([t['boxes'][i] for t, (_, i) in zip(targets, indices)], dim=0)
# 计算L1损失
loss_bbox = F.l1_loss(src_boxes, target_boxes, reduction='none')
losses = {}
# 将边界框损失按照边界框数量归一化
losses['loss_bbox'] = loss_bbox.sum() / num_boxes
# 计算广义交并比(GIoU)损失
loss_giou = 1 - torch.diag(box_ops.generalized_box_iou(
box_ops.box_cxcywh_to_xyxy(src_boxes),
box_ops.box_cxcywh_to_xyxy(target_boxes)))
# 将GIoU损失按照边界框数量归一化
losses['loss_giou'] = loss_giou.sum() / num_boxes
return losses
针对目标检测的transformer改进
分析完训练最关键的:双边匹配+loss计算部分,现在需要考虑在目标检测算法中transformer如何设计?下面按照算法的4个步骤讲解。
transformer细节如下:
bcnn骨架特征提取
骨架网络可以是任何一种,作者选择resnet50,将最后一个stage即stride=32的特征图作为编码器输入。由于resnet仅仅作为一个小部分且已经经过了imagenet预训练,故和常规操作一样,会进行如下操作:
- resnet中所有BN都固定,即采用全局均值和方差
- resnet的stem和第一个stage不进行参数更新,即parameter.requires_grad_(False)
- backbone的学习率小于transformer,lr_backbone=1e-05,其余为0.0001
假设输入是(b,c,h,w),则resnet50输出是(b,2048,h//32,w//32),2048比较大,为了节省计算量,先采用1x1卷积降维为256,最后转化为序列格式输入到transformer中,输入shape=(h’xw’,b,256),h’=h//32
self.input_proj = nn.Conv2d(backbone.num_channels, hidden_dim, kernel_size=1)
# 输出是(b,256,h//32,w//32)src=self.input_proj(src)
# 变成序列模式,(h'xw',b,256),256是每个词的编码长度
src = src.flatten(2).permute(2, 0, 1)
编码器设计和输入
编码器结构设计没有任何改变,但是输入改变了。
a) 位置编码需要考虑2d空间
由于图像特征是2d特征,故位置嵌入向量也需要考虑xy方向。前面说过编码方式可以采用sincos,也可以设置为可学习,本文采用的依然是sincos模式,和前面说的一样,但是需要考虑xy两个方向(前面说的序列只有x方向)。
两个方向(前面说的序列只有x方向)。
# 输入数据的形状为(batch_size, channels, height, width)
# tensor_list是NestedTensor类型,包含了数据张量和对应的mask
# 这个mask用于处理不同尺寸图像的情况,因为批量处理时所有图像会被pad到相同的尺寸
x = tensor_list.tensors # 获取原始的图像数据张量
mask = tensor_list.mask # 获取图像的mask,形状为(batch_size, height, width),每个元素标识是否为填充部分
not_mask = ~mask # 取反mask,标记图像的非填充部分
# 由于图像是二维的,位置编码也分为x和y两个方向
# 在y方向上,同一列的位置编号是相同的:1 1 1 1... 2 2 2 2...
y_embed = not_mask.cumsum(1, dtype=torch.float32) # 在y方向上累加,生成每个位置的y编码
# 在x方向上,同一行的位置编号是连续的:1 2 3 4... 1 2 3 4...
x_embed = not_mask.cumsum(2, dtype=torch.float32) # 在x方向上累加,生成每个位置的x编码
if self.normalize:
eps = 1e-6 # 定义一个小的常数,防止除以零
# 对y_embed和x_embed进行归一化,并乘以缩放因子self.scale
y_embed = y_embed / (y_embed[:, -1:, :] + eps) * self.scale
x_embed = x_embed / (x_embed[:, :, -1:] + eps) * self.scale
# num_pos_feats通常是模型输入维度的一半,因为位置编码的一半用sin函数,另一半用cos函数
dim_t = torch.arange(self.num_pos_feats, dtype=torch.float32, device=x.device)
dim_t = self.temperature ** (2 * (dim_t // 2) / self.num_pos_feats) # 按照位置编码的规则计算缩放因子
# 分别计算x方向和y方向上的位置编码
pos_x = x_embed[:, :, :, None] / dim_t
pos_y = y_embed[:, :, :, None] / dim_t
# 应用正弦和余弦函数来生成最终的位置编码,然后将其扁平化
pos_x = torch.stack((pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4).flatten(3)
pos_y = torch.stack((pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4).flatten(3)
# 将x方向和y方向的位置编码拼接起来,并调整维度顺序以匹配(batch_size, channels, height, width)的格式
pos = torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2)
# 最终输出的每个特征图位置都被编码为一个256维的向量,前128维为y方向编码,后128维为x方向编码
return pos # 形状为(batch_size, n=256, height, width)
可以看出对于h//32,w//32的2d图像特征,不是类似vision transoformer做法简单的将其拉伸为h//32 x w//32,然后从0-n进行长度为256的位置编码,而是考虑了xy方向同时编码,每个方向各编码128维向量,这种编码方式更符合图像特定。
还有一个细节需要注意:原始transformer的n个编码器输入中,只有第一个编码器需要输入位置编码向量,但是detr里面对每个编码器都输入了同一个位置编码向量,论文中没有写为啥要如此修改。
b) QKV处理逻辑不同
作者设置编码器一共6个,并且位置编码向量仅仅加到QK中,V中没有加入位置信息,这个和原始做法不一样,原始做法是QKV都加上了位置编码,论文中也没有写为啥要如此修改。
其余地方就完全相同了,故代码就没必要贴了。总结下和原始transformer编码器不同的地方:
- 输入编码器的位置编码需要考虑2d空间位置
- 位置编码向量需要加入到每个编码器中
- 在编码器内部位置编码仅仅和QK相加,V不做任何处理
经过6个编码器forward后,输出shape为(h//32xw//32,b,256)。
c) 编码器部分整体运行流程
6个编码器整体forward流程如下:
def forward_post(self,
src,
src_mask: Optional[Tensor] = None,
src_key_padding_mask: Optional[Tensor] = None,
pos: Optional[Tensor] = None):
# 与标准的Transformer略有不同,这里q(query)和k(key)是将输入特征与位置编码相加得到的,
# 但是v(value)仍然使用原始的输入特征src,也就是说在计算注意力时,value并没有加上位置信息。
q = k = src + pos
# 计算自注意力机制的输出,这里使用了自定义的注意力层。
# self_attn函数的返回值是一个元组,我们只需要第一个元素,即注意力操作的结果。
src2 = self.self_attn(q, k, value=src, attn_mask=src_mask,
key_padding_mask=src_key_padding_mask)[0]
# 将注意力机制的输出与输入进行残差连接,并应用dropout。
src = src + self.dropout1(src2)
# 应用LayerNorm进行层归一化。
src = self.norm1(src)
# 通过前馈网络(两个线性层和一个激活函数),并在中间使用dropout。
src2 = self.linear2(self.dropout(self.activation(self.linear1(src))))
# 再次进行残差连接和dropout。
src = src + self.dropout2(src2)
# 最后一次LayerNorm层归一化。
src = self.norm2(src)
return src
class TransformerEncoder(nn.Module):
def __init__(self, encoder_layer, num_layers, norm=None):
super().__init__()
# 将给定的编码器层复制多份,这里通常是六份。
self.layers = _get_clones(encoder_layer, num_layers)
self.num_layers = num_layers
# 可选的归一化层。
self.norm = norm
def forward(self, src,
mask: Optional[Tensor] = None,
src_key_padding_mask: Optional[Tensor] = None,
pos: Optional[Tensor] = None):
# 顺序执行每个编码器层,通常有六层。
# src为图像特征输入,其形状为(height*width, batch_size, 256)。
output = src
for layer in self.layers:
# 每个编码器层的输入需要加上位置编码pos。
# 第一个编码器层的输入来自于图像特征,之后的编码器层则使用前一个层的输出作为输入。
output = layer(output, src_mask=mask,
src_key_padding_mask=src_key_padding_mask, pos=pos)
# 若定义了归一化层,则在所有编码器层运行完成后执行归一化。
if self.norm is not None:
output = self.norm(output)
return output
解码器设计和输入
解码器结构设计没有任何改变,但是输入也改变了。
a) 新引入Object queries
object queries(shape是(100,256))可以简单认为是输出位置编码,其作用主要是在学习过程中提供目标对象和全局图像之间的关系,相当于全局注意力,必不可少非常关键。代码形式上是可学习位置编码矩阵。和编码器一样,该可学习位置编码向量也会输入到每一个解码器中。我们可以尝试通俗理解:object queries矩阵内部通过学习建模了100个物体之间的全局关系,例如房间里面的桌子旁边(A类)一般是放椅子(B类),而不会是放一头大象(C类),那么在推理时候就可以利用该全局注意力更好的进行解码预测输出。
num_queries=100,hidden_dim=256
self.query_embed = nn.Embedding(num_queries, hidden_dim)
论文中指出object queries作用非常类似faster rcnn中的anchor,只不过这里是可学习的,不是提前设置好的。
Object queries在Transformer架构中的作用确实与Faster R-CNN中的anchors有类似之处,不过object queries是学习得到的而非预设的。在DETR(Detection with Transformers)这类模型中,object queries可以被视为一种学习到的“提问”,它们向模型询问图像中是否存在某种特定的目标物体。
下面我将用一个例子来通俗解释这个过程:
想象一个场景,我们的任务是在一个房间里找到不同的物品,比如大象、狗和猫。我们的工具是一个带有一些空白便签的钥匙环,这些便签将被用来记下我们发现物品的位置信息。
- 训练阶段:
- 每个便签(即object query)都是空白的,我们需要在训练过程中填写它们。
- 在我们的训练过程中,我们会看很多房间(即训练图片),并且每当我们在某个位置发现一个大象,我们就在对应的便签上做个记号。
- 随着时间的推移,便签上累积了各种各样的记号,每个便签都专门对应于寻找某一特定位置的某一类物体。
- 最终,每个便签变成了一个信息密集的提示卡,上面记录了如果在某个位置找到大象的话它通常看起来是什么样子。
- 测试阶段:
- 现在我们走进一个新房间(即测试图片),我们带着钥匙环上的便签(已经过训练,包含了丰富信息的object queries)。
- 我们开始寻找大象。我们拿起与寻找大象相关的便签,并询问:“这个位置有大象吗?”
- 我们的便签(object query)与房间(图片特征)中的每个位置进行比对,尝试找到匹配的信息。
- 当便签找到一个看起来与它记载信息相匹配的位置时,它会“兴奋”起来,这意味着我们可能找到了一个大象。
- 重复这个过程,我们会逐个使用便签(每个object query)去找狗、猫或者其他任何我们在训练过程中学习到的物体。
- 最后,我们会把所有便签上的“兴奋”点集中起来,这些就是我们在房间中找到的目标物品的信息。
在这个例子中,便签上的记号就是通过训练累积的关于不同物品在不同位置的外观信息。测试时,这些便签(object queries)被用来从图片中提取对应的特征,以预测物品的类别和位置。这个过程是动态的,并且随着训练的进行,便签上的信息会变得越来越丰富和精确。这就是为什么它们比静态的anchors(如Faster R-CNN中使用的)更加强大,因为它们能够在整个训练集上捕获和学习统计信息,而不是依赖预设的大小和比例。
在整个分析过程中可以总结下:object queries在训练过程中对于N个格子会压缩入对应的和位置和类别相关的统计信息,在测试阶段就可以利用该Q去和编码特征KV计算加权计算,从而提出想要的对齐的特征,最后进行分类和回归。
b) 位置编码也需要
编码器环节采用的sincos位置编码向量也可以考虑引入,且该位置编码向量输入到每个解码器的第二个Multi-Head Attention中,后面有是否需要该位置编码的对比实验。
c) QKV处理逻辑不同
解码器一共包括6个,和编码器中QKV一样,V不会加入位置编码。上述说的三个操作,只要看下网络结构图就一目了然了。
d) 一次解码输出全部无序集合
原始transformer顺序解码操作不同的是,detr一次就把N个无序框并行输出了(因为任务是无序集合,做成顺序推理有序输出没有很大必要)。
总结下和原始transformer解码器不同的地方:
- 额外引入可学习的Object queries,相当于可学习anchor,提供全局注意力
- 编码器采用的sincos位置编码向量也需要输入解码器中,并且每个解码器都输入
- QKV处理逻辑不同
- 不需要顺序解码,一次即可输出N个无序集合
e) 解码器整体运行流程
# 这个函数是Transformer解码器中每一个解码层的前向传播函数
# 内部每个解码器运行流程为
def forward_post(self, tgt, memory,
tgt_mask: Optional[Tensor] = None,
memory_mask: Optional[Tensor] = None,
tgt_key_padding_mask: Optional[Tensor] = None,
memory_key_padding_mask: Optional[Tensor] = None,
pos: Optional[Tensor] = None,
query_pos: Optional[Tensor] = None):
# q和k是query和key,对于自注意力机制是相同的。这里它们是目标(tgt)加上位置编码(query_pos)。
q = k = tgt + query_pos
# 第一个自注意力模块,注意力操作是在tgt自己之间进行的。
tgt2 = self.self_attn(q, k, value=tgt, attn_mask=tgt_mask,
key_padding_mask=tgt_key_padding_mask)[0]
# 应用dropout,然后将自注意力的输出与原来的tgt相加,实现残差连接。
tgt = tgt + self.dropout1(tgt2)
# 应用层归一化
tgt = self.norm1(tgt)
# 在多头注意力层中,query是tgt加上其位置编码query_pos,而key是编码器输出的memory加上其位置编码pos。
# value是直接使用memory。
tgt2 = self.multihead_attn(query=tgt + query_pos,
key=memory + pos,
value=memory, attn_mask=memory_mask,
key_padding_mask=memory_key_padding_mask)[0]
# 同样地,应用dropout和残差连接。
tgt = tgt + self.dropout2(tgt2)
# 再次应用层归一化
tgt = self.norm2(tgt)
# 接着是一个全连接层(Feed Forward Network, FFN),首先是线性变换,然后是激活函数,最后是dropout。
tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt))))
# 应用dropout和残差连接
tgt = tgt + self.dropout3(tgt2)
# 最后一次层归一化
tgt = self.norm3(tgt)
# 返回经过一个解码层处理过的tgt。
return tgt
# 这个类定义了Transformer解码器,由多个解码层组成
# n个解码器整体流程如下
# 解码器最终输出shape是(6,b,100,256),6是指6个解码器的输出
class TransformerDecoder(nn.Module):
def forward(self, tgt, memory,
tgt_mask: Optional[Tensor] = None,
memory_mask: Optional[Tensor] = None,
tgt_key_padding_mask: Optional[Tensor] = None,
memory_key_padding_mask: Optional[Tensor] = None,
pos: Optional[Tensor] = None,
query_pos: Optional[Tensor] = None):
# query_pos是学习得到的查询位置向量,其形状通常是(查询数量, batch大小, 特征维度)
output = tgt
# intermediate用于存储每个解码层的输出
intermediate = []
# 逐个解码层进行处理
for layer in self.layers:
output = layer(output, memory, tgt_mask=tgt_mask,
memory_mask=memory_mask,
tgt_key_padding_mask=tgt_key_padding_mask,
memory_key_padding_mask=memory_key_padding_mask,
pos=pos, query_pos=query_pos)
if self.return_intermediate:
intermediate.append(self.norm(output))
# 如果需要返回中间结果,就把所有解码层的输出堆叠起来
if self.return_intermediate:
return torch.stack(intermediate)
# 否则只返回最后一个解码层的输出
return output.unsqueeze(0)
# 分类头是一个线性层,用于将解码器的输出转换为预测类别的logits。
# 分类和回归head
self.class_embed = nn.Linear(256, 92)
# bbox回归头是一个多层感知机,输出边界框的坐标
self.bbox_embed = MLP(256, 256, 4, 3)
# hs是解码器的输出,形状是(层数, batch大小, 查询数量, 特征维度)
# outputs_class是每个解码层输出的分类logits,形状是(层数, batch大小, 查询数量, 类别数)
outputs_class = self.class_embed(hs)
# outputs_coord是每个解码层输出的边界框坐标,形状是(层数, batch大小, 查询数量, 坐标数)
outputs_coord = self.bbox_embed(hs).sigmoid()
# 我们通常只关注最后一个解码层的输出
out = {'pred_logits': outputs_class[-1], 'pred_boxes': outputs_coord[-1]}
# 如果辅助损失被激活,则计算并返回除最后层以外所有层的输出
if self.aux_loss:
out['aux_outputs'] = self._set_aux_loss(outputs_class, outputs_coord)
整体推理流程
基于transformer的detr算法,作者特意强调其突出优点是部署代码不超过50行,简单至极。
当然上面是简化代码,和实际代码不一样。具体流程是:
- 将(b,3,800,1200)图片输入到resnet50中进行特征提取,输出shape=(b,1024,25,38)
- 通过1x1卷积降维,变成(b,256,25,38)
- 利用sincos函数计算位置编码
- 将图像特征和位置编码向量相加,作为编码器输入,输出编码后的向量,shape不变
- 初始化全0的(100,b,256)的输出嵌入向量,结合位置编码向量和query_embed,进行解码输出,解码器输出shape为(6,b,100,256),后面的解码器接受该输出,然后再次结合置编码向量和query_embed进行输出,不断前向
- 将最后一个解码器输出输入到分类和回归head中,得到100个无序集合
- 对100个无序集合进行后处理,主要是提取前景类别和对应的bbox坐标,乘上(800,1200)即可得到最终坐标,后处理代码如下:
# 对输出的类别logits应用softmax函数来获得预测概率
prob = F.softmax(out_logits, -1)
# 从预测概率中找到每个类别的最大概率值和对应的标签(忽略最后一类,通常为背景类)
scores, labels = prob[..., :-1].max(-1)
# 将输出的边界框(通常是中心点坐标+宽高格式)转换成对角坐标格式[x0, y0, x1, y1]
boxes = box_ops.box_cxcywh_to_xyxy(out_bbox)
# 将相对坐标[0, 1]转换为绝对坐标[0, height]或[0, width],
# 即把归一化的坐标值转换为依据图像尺寸的实际像素值。
img_h, img_w = target_sizes.unbind(1)
scale_fct = torch.stack([img_w, img_h, img_w, img_h], dim=1) # 创建缩放因子
boxes = boxes * scale_fct[:, None, :] # 应用缩放因子到边界框
# 将分数、标签和边界框组织成字典格式,每个元素对应一个检测结果
results = [{'scores': s, 'labels': l, 'boxes': b} for s, l, b in zip(scores, labels, boxes)]
这段代码主要用于处理模型输出的后处理步骤,将模型的原始输出转换为可用于评估或进一步处理的格式。在对象检测任务中,这个转换是必要的步骤,用于将模型的输出转换成标准的检测框格式和类别置信度。
实验分析
(1) 性能对比
Faster RCNN-DC5是指的resnet的最后一个stage采用空洞率=stride设置代替stride,目的是在不进行下采样基础上扩大感受野,输出特征图分辨率保持不变。+号代表采用了额外的技巧提升性能例如giou、多尺度训练和9xepoch训练策略。可以发现detr效果稍微好于faster rcnn各种版本,证明了视觉transformer的潜力。但是可以发现其小物体检测能力远远低于faster rcnn,这是一个比较大的弊端。
- 特征分辨率:Faster R-CNN等传统的检测方法通常采用多尺度或金字塔结构来捕捉不同大小的特征。在使用空洞卷积(如DC5)时,可以在不减少特征图分辨率的情况下增大感受野,这对小物体特别有利。DETR缺少这种内置的多尺度结构,因此可能在捕获小物体的特征方面不如卷积网络。
- 全局上下文信息:虽然Transformer可以捕捉长距离依赖,提供全局的上下文信息,但这种能力可能对小物体的局部特征识别不够敏感,尤其是当小物体周围有复杂背景时。
- 训练数据和正则化:DETR需要大量的训练数据和合理的正则化来泛化小物体的检测。由于Transformer结构的自注意力机制会涉及到大量的计算,因此对小物体特征的学习可能不如对大物体那样充分。
- 对象查询和位置编码:DETR使用固定数量的对象查询来检测图像中的物体,这些查询需要在训练过程中学习到检测小物体的能力。如果这些查询不能很好地代表小物体,可能会影响检测性能。
(2) 各个模块分析
编码器数目越多效果越好,但是计算量也会增加很多,作者最终选择的是6。
可以发现解码器也是越多越好,还可以观察到第一个解码器输出预测效果比较差,增加第二个解码器后性能提升非常多。上图中的NMS操作是指既然我们每个解码层都可以输入无序集合,那么将所有解码器无序集合全部保留,然后进行nms得到最终输出,可以发现性能稍微有提升,特别是AP50。
作者对比了不同类型的位置编码效果,因为query_embed(output pos)是必不可少的,所以该列没有进行对比实验,始终都有,最后一行效果最好,所以作者采用的就是该方案,sine at attn表示每个注意力层都加入了sine位置编码,相比仅仅在input增加位置编码效果更好。
(3) 注意力可视化
前面说过transformer具有很好的可解释性,故在训练完成后最终提出了几种可视化形式
a) bbox输出可视化
这个就比较简单了,直接对预测进行后处理即可
probas = outputs['pred_logits'].softmax(-1)[0, :, :-1]
# 只保留概率大于0.9的bboxkeep = probas.max(-1).values > 0.9
# 还原到原图,然后绘制即可
bboxes_scaled = rescale_bboxes(outputs['pred_boxes'][0, keep], im.size)
plot_results(im, probas[keep], bboxes_scaled)
b) 解码器交叉注意力层权重可视化
这里指的是最后一个解码器内部的第一个MultiheadAttention的交叉注意力权重(因为KV来自编码器,Q来自解码器,所以叫做交叉注意力),其实就是QK相似性计算后然后softmax后的输出可视化,具体是:
# multihead_attn注册前向hook,output[1]指的就是softmax后输出
model.transformer.decoder.layers[-1].multihead_attn.register_forward_hook(
lambda self, input, output: dec_attn_weights.append(output[1])
)
# 假设输入是(1,3,800,1066)
outputs = model(img)
# 那么dec_attn_weights是(1,100,850=800//32x1066//32)
# 这个就是QK相似性计算后然后softmax后的输出,即注意力权重
dec_attn_weights = dec_attn_weights[0]
# 如果想看哪个bbox的权重,则输入idx即可
dec_attn_weights[0, idx].view(800//32, 1066//32)
c) 编码器自注意力层权重可视化
这个和解码器操作完全相同。
model.transformer.encoder.layers[-1].self_attn.register_forward_hook(
lambda self, input, output: enc_attn_weights.append(output[1])
)
outputs = model(img)
# 最后一个编码器中的自注意力模块权重输出(b,h//32xw//32,h//32xw//32),其实就是qk计算然后softmax后的值即(1,25x34=850,850)
enc_attn_weights = enc_attn_weights[0]
# 变成(25, 34, 25, 34)
sattn = enc_attn_weights[0].reshape(shape + shape)
# 想看哪个特征点位置的注意力
idxs = [(200, 200), (280, 400), (200, 600), (440, 800), ]
for idx_o, ax in zip(idxs, axs):
# 转化到特征图尺度
idx = (idx_o[0] // fact, idx_o[1] // fact)
# 直接sattn[..., idx[0], idx[1]]即可
ax.imshow(sattn[..., idx[0], idx[1]], cmap='cividis', interpolation='nearest')
小结
detr整体做法非常简单,基本上没有改动原始transformer结构,其显著优点是:不需要设置啥先验,超参也比较少,训练和部署代码相比faster rcnn算法简单很多,理解上也比较简单。但是其缺点是:改了编解码器的输入,在论文中也没有解释为啥要如此设计,而且很多操作都是实验对比才确定的,比较迷。算法层面训练epoch次数远远大于faster rcnn(300epoch),在同等epoch下明显性能不如faster rcnn,而且训练占用内存也大于faster rcnn。
整体而言,虽然效果不错,但是整个做法还是显得比较原始,很多地方感觉是尝试后得到的做法,没有很好的解释性,而且最大问题是训练epoch非常大和内存占用比较多,对应的就是收敛慢。
主要参考文章
3W字长文带你轻松入门视觉transformer
Attention Is All You Need(注意力模型)
The Illustrated Transformer
有没有比较详细通俗易懂的Transformer教程? - 知乎