**
上篇有很多朋友照搬了我的yaml结构,这里抱歉下也有原因是我的工作偏工程,真的研究时间有限!且温馨提示:如果耐心看完我的文章,应该了解我的本意是分享和大家一起交流,在V5的代码风格下,我们可以注册自己的算子函数,鼓励大家动手且动脑去研究,yaml只是一个参考案例!关键在于大家理解后去实践去改进!今天这篇一样,上篇是侧重添加的话,这篇侧重于修改,要是分享算法和提供适配V5的算子和代码块或者迁移好的代码,还是希望大家一起研究和交流,最好能把实验结果反馈下,这也是我的一点小请求~我也在业余做实验中,目前git 仓库我将上个月的改动上传了我的部分v5git,其余还在修改和实验中,供大家参考实验和交流。
git 地址:my yolov5
相关transformer文章理解,开头谈到了检测的一个经典问题
一点就分享系列(实践篇3-上篇)— 修改YOLOV5 之”魔刀小试“+ Trick心得分享+V5精髓部分源码解读
虽迟但到, 首先回顾下,在关于上篇yolov5主要介绍了:
1. 其V5的核心训练逻辑;
2. 还有基于注意力机制和特征融合层的添加,为了让读者具备自己改进的能力;
3. 阐述了额外的改进点tricks和自己当时对结果的直观理解和认识,比如加检测头以及引进自注意力机制和IOU LOSS等等。
目前,YOLOV5 的V5.0版本.,算法层面主要的改动就是已经证明了加大尺度和增加检测层确实work:
(关于EIOU(请确认没有用focal的系数,或者纯EIOU效果不行,那就切换CIOU训练))
在上篇中也说到了提升不大或者说带来那1%的提升增加的额外算力没什么意义,这也不是上篇的初衷,上篇本质是希望大家了解yolov5的精髓并且具备动手修改网络结构以及改进的能力,和对模型的一些深度思考哈。在此基础上,我今天抛出的东西仍旧是开放性的!希望大家都能动手动脑去思考去理解!由于个人工作原因,更新较慢,故此篇我会多赠送一些东西~也希望各位真正有兴趣投身CV行业的能够互相交流和学习分享!
**本篇文章,将分享点适配yolov5如下,提供算子和Bottleneck等,我代码中的yaml只是示例,感兴趣的也可以随意自己适配在yolov5的整体baseline中!**温馨提示:改动是我基于算子去和V5的block作了融合。
1.内卷invloution算子以及block;
2. transformer bottleneck ;
4. 后面更新:杂揉DIY组合中,新版本transformer实验中!!!!!!!!!!!!
说是实践篇,但是嘛理论照样过一过
源码地址:内卷算子?github!!!!!!!!!!!!!!!!!!!
想要了解Involution,我们先来说下卷积的特点:参数共享和通道独立:
众所周知,CNN再不同位置是共享卷积核参数的,而通道独立是指每个通道中的卷积核是不同的,所以卷积再空间上并不敏感且再输出的每个通道上使用了不同的卷积核。
那么内卷积呢,顾名思义?反着来!
作者认为,可以自适应地为不同空间位置分配不同的权重,有利于在空间域上“找出”对前景贡献度更高的视觉元素。Involution能够避免卷积现有的归纳偏置问题,并且指出,在学习视觉表征的过程中,像self-attention那样结合两两像素对的形式来进行关系建模也不是必要。
这样看似是否真如作者所说,involution的设计还和transformer的self-attention有比较。后面详细说明。
invloution的卷积核对应在一个空间位置上的参数值,且之和这个位置对应输入的feature map的向量有关:
那么Xij就是对应位置(i,j)的特征向量,而W可以理解为则是线性权重矩阵(线性变换即可),经过该计算我们得到的就是卷积核在(i,j)位置的参数值。
而W0和W1通过1x1卷积 该变通道数和SENET计算通道注意力是一样的操作,压缩和扩张。然后输出的通道数会分程G个组,其中每组内部共享该核的参数,这里不同组还是不同的核。
这里由于是在空间维度上是一对一的,所以内卷积的尺寸和输入feature map相等。对尺度H和W不进行下采样,这样就能保证输出的尺寸仍是H x W ,从而使得kernel与特征图在空间位置上一一对应,满足空间特异性。
最终,我们计算得到的一个Kernel,shape:[(Batch,GxKxK,h,w)]
但是问题来, HxW是输入的空间尺寸,而kernel核的大小是KxK,我们要求对应相条,那么目前的shape是对不上的,所以这里要使用
unfold这个操作,然后再Multiply-add(只不过是unfold后再与kernel进行Multiply-add),变为一个空间位置的输出特征点,而各个(输入)通道之间是不会进行相加的。
由于组内通道共享,那Kerel的Size可以适当增大,特征在一个位置上的信息获取和共享范围就变得更大,那么是不是有点和sllf-attention相似,更有利于捕获长距离信息。
our involution tackles this dilemma through sharing meta-weights of the kernel generation function across different positions, though not directly the weights of kernel instances.
Involution虽然没有在空间位置上共享kernel参数,潜在引入了知识共享与迁移,因为其kernel产生的算子内部是卷积操作,仍然具备在不同位置上共享参数的特点。
看过代码和理解原理后,你会发现经过Invoution后,tensor的通道数是不会有变化的,因为其通道具备共享性,如果要做通道扩展,那么就会使用不同的卷积核,那就成了卷积计算量同时也会增大。
Involution kernel是动态的,同一batch内的每个featuremap都是经过卷积生成的,且对应的kernel是不一样的,因为空间独立性;反观卷积核则是动态的,同一个batch内的每个feature map使用的是固定的kernel参数,因为其参数共享。
虽然在同一组内的通道之间共享kernel,这在一定程度上实现了信息共享(迁移),但在所有通道之间的信息交换方面始终受到了一定程度的局限??组内的每一个输出通道信息只来自于对应的一个输入通道,因为involution在输入的通道之间没有进行add等交互操作。在下面的代码中,可以看到计算步骤,Multiply-add只在最后生成新的featuremap出现。
以上大多都是和CNN进行对比,也提到过其具备长距离的上下本信息聚合特点,那么与transfrmer的self-attention呢?
仔细看代码和计算步骤的朋友不难发现,其实Involution的计算是由2D 卷积和Multiply-add计算来组成的,而self-attention是包含了点乘。
又到了手写推导原理的时刻了,最简单化公式并给出我的理解,舒服了:
self-attention中不同的head对应到involution中不同的group(在通道上划分的组);
self-attention中每个位置的关系矩阵Q*K,对应到involution中每个位置的kernel
结论就是:self-attention可能是Involution的特殊化延申和实例??那么公式结论是,Involution 等价于 self-attention的相似度矩阵(query-key relation)?
换个作者的说法,involition生成的kernel是包含于position embedding和query-key relation的。
看完后,是不是感觉很舒服?如果自注意力机制的,Q,K,V是共享权重的参数呢?(在swin中是这样的,原版本不是这是为了加强信息捕捉能力,但是结合CNN的局部层级结构如果共享参数未尝不可?)
involution还不需要空间位置编码向量!因为根据featuremap的空间点(involution)生成的新feturemap.
self-attention本质可能是捕捉长距离信息和自适应的交互信息?这通常需要一个large & dynamic的kernel来实现,但这个kernel的构建则并非一定要用Query-Key relation。作者好牛!!!!!
import torch import torch.nn as nn class Involution(nn.Module): def __init__(self, channels, kernel_size=7, stride=1, group_channels=16, reduction_ratio=4): super().__init__() assert not (channels % group_channels or channels % reduction_ratio) # in_c=out_c self.channels = channels self.kernel_size = kernel_size self.stride = stride # 每组多少个通道 self.group_channels = group_channels self.groups = channels // group_channels # reduce channels 通道压缩 self.reduce = nn.Sequential( nn.Conv2d(channels, channels // reduction_ratio, 1), nn.BatchNorm2d(channels // reduction_ratio), nn.ReLU() ) # span channels通道扩张 self.span = nn.Conv2d( channels // reduction_ratio, self.groups * kernel_size ** 2, 1 ) if stride > 1: self.avgpool = nn.AvgPool2d(stride, stride) self.unfold = nn.Unfold(kernel_size, 1, (kernel_size-1)//2, stride) def forward(self, x): # generate involution kernel: (b,G*K*K,h,w) weight = self.span(self.reduce(self.down_sample(x))) b, _, h, w = weight_matrix.shape ##获取特征输入的空间尺度h,w # unfold input: (b,C,h,w)->(b,C*K*K,h,w) x_unfolded = self.unfold(x) #unflold操作 新shape制造 # (b,C*K*K,h,w)->(b,G,C//G,K*K,h,w) x_unfolded = x_unfolded.view(b, self.groups, self.group_channels, self.kernel_size ** 2, h, w) # (b,G*K*K,h,w) -> (b,G,1,K*K,h,w) 将输入通道也分为G个组,由于G通常小于通道数C,因此每个组内有多个通道,即使得每组内的多个输入通道都对应到同一个kernel,同时和x__unfold 进行Multiply-add,这里也对应了上面组内共享一个kernel weight = weight.view(b, self.groups, self.kernel_size**2, h, w).unsqueeze(2) # (b,G,C//G,h,w) mul_add = (weight * x_unfolded).sum(dim=3) # (b,C,h,w) out = mul_add.view(b, self.channels, h, w) return out
作者对kernel size、一组中共享卷积核的通道数以及生成kernel的形式(线性变换矩阵W和通道压缩率r)进行了消融实验。基于以上实验结果,我们可以推断出:
参数量、计算量降低,性能反而提升能加在各种模型的不同位置替换convolution,比如backbone,neck和head,一般来讲替换的部分越多,模型性价比越高
kernel size越大,性能越高,这应该是large
kernel促进了长距离交互导致,但同时参数量会增涨。于是,为了取得一个trade-off,最终作者选择了7x7大小的kernel;
通道共享可以降低参数量,但同时会降低性能,并且共享一个卷积核的特征通道数越多,性能越低,说明对各个特征通道进行独立编码效果最好。但是,可以发现,共享一个卷积核的特征通道数由4上升到16之后性能并未降低,因此也可以印证前文所说的:内部通道之间的信息确实存在冗余。同样地,综合性能与空间和计算效率,作者最终选择Group
Channel = 16,也就是将每16个特征通道划分为一组,组内共享一个卷积核;
在生成kernel时,使用多个线性变换矩阵对通道进行压缩可以降低参数量,但也会降低性能。依旧,为了trade-off,作者最终使用两个线性变换,先对通道进行压缩,后再恢复,其中压缩率r=4
相关transformer文章理解,开头还谈到了检测的一个经典问题
那么,这个transformer作为bottleneck里不仅减少参数量,还巧妙避开了这个图像Patch的处理问题,因只提供给我们基于图像的transforme bottleneck。之前在transformer中也提到了,不管是最近的swin-transformer也好,都在作两个方向,transformer和我们熟悉的CNN结构处理迁移和SELF-attention的计算问题。
总之,一句话:这篇paper告诉我们,用self-attention替换 3*3卷积层,带来更好的性能且参数量还减少了。
如果想要自己实现的话也不难,借鉴上图的逻辑,其实就是我们实现这个核心模块的算法,然后先说下外层Bottleneck的不同点:
BotNet中用的是batch normalization。和传统的Transformer不同的还有:在Transformer一个FFN里面只有1个激活函数,而在BoTNet中一个block有3个激活函数。这里留下个疑问,我觉得为什么要一定要3个?还有Bottleneck结构我随意设置不行嘛,理论上我也觉得没什么说不通的地方,所以我在自己的代码中,除了MHSA的计算逻辑,剩下是自己设置的。
对比下,不难发现改动就是将bootleneck中3X3卷积换成了MHSA层。
**,迁移到自己的backbone要计算好w和H,如果适配自己的模型需要进行一番改动,当然也可以照搬。
计算流程简述:
注意看上图,这里Botnet的作者是定义了可学习的固定尺度的位置编码向量Rh和Rw (包含x和y两个方向),通过相加完成Content-position。 而另外一边,我们需要构造好Q,K,V ,QK做相似度矩阵计算得到content-content,这是我们和content-position相加,进行后修的softamx和点乘计算。
class MHSA(nn.Module): def __init__(self, n_dims, width=14, height=14, heads=4): super(MHSA, self).__init__() self.heads = heads self.query = nn.Conv2d(n_dims, n_dims, kernel_size=1) self.key = nn.Conv2d(n_dims, n_dims, kernel_size=1) self.value = nn.Conv2d(n_dims, n_dims, kernel_size=1) self.rel_h = nn.Parameter(torch.randn([1, heads, n_dims // heads, 1, height]), requires_grad=True) #可学习的高方向位置编码 self.rel_w = nn.Parameter(torch.randn([1, heads, n_dims // heads, width, 1]), requires_grad=True) #可学习的宽方向位置编码 self.softmax = nn.Softmax(dim=-1) def forward(self, x): n_batch, C, width, height = x.size() q = self.query(x).view(n_batch, self.heads, C // self.heads, -1) #定义Q,K,V tensor,如图需要先经过1X1卷积 k = self.key(x).view(n_batch, self.heads, C // self.heads, -1) v = self.value(x).view(n_batch, self.heads, C // self.heads, -1) content_content = torch.matmul(q.permute(0, 1, 3, 2), k) #1, 4, 64, 64 #点乘相似度矩阵计算 content_position = (self.rel_h + self.rel_w).view(1, self.heads, C // self.heads, -1).permute(0, 1, 3, 2) #reshape content_position = torch.matmul(content_position, q) #1,4,512,64 #按botnet的MHSA思路 计算qrt 位置编码和Q做点乘 energy = content_content + content_position #相加,后面就是self-attention的常规公式计算 attention = self.softmax(energy) out = torch.matmul(v, attention.permute(0, 1, 3, 2)) out = out.view(n_batch, C, width, height) return out class TransfomerBottleneck(nn.Module): # Conv 是V5作者封装的 conv2d+bn+reulu/silu, 大家可以自己修改,按照算法这个设计思路是一样的 def __init__(self, c1, c2, stride=1, heads=4, mhsa=True, resolution=None,expansion=4): super(BottleneckTransformer, self).__init__() c_=int(c2) self.cv1 = Conv(c1, c_, 1,1) #self.bn1 = nn.BatchNorm2d(c2) if not mhsa: self.cv2 = Conv(c_,c2, 3, 1) else: self.cv2 = nn.ModuleList() self.cv2.append(MHSA(c_, width=int(resolution[0]), height=int(resolution[1]), heads=heads)) if stride == 2: self.cv2.append(nn.AvgPool2d(2, 2)) self.cv2 = nn.Sequential(*self.cv2) #self.bn2 = nn.BatchNorm2d(planes) #self.cv3 = nn.Conv2d(planes, expansion * planes, kernel_size=1, bias=False) #self.bn3 = nn.BatchNorm2d(expansion * planes) self.shortcut = nn.Sequential() #self.shortcut = c1==c2 if stride != 1 or c1 != expansion*c2: self.shortcut = nn.Sequential( nn.Conv2d(c1, expansion*c2, kernel_size=1, stride=stride), nn.BatchNorm2d(expansion*c2) ) # self.fc1 = nn.Linear(c2, c2) def forward(self, x): #和传统的Transformer不同的还有:在Transformer一个FFN里面只有一个非线性激活函数,而在BoTNet中一个block有三个非线性激活函数,这里我自己改成了YOLOV5的适配代码,是两个激活函数。 #print("transforme input bottleck shape:",x.shape) # out = F.relu(self.bn1(self.conv1(x))) # out = F.relu(self.bn2(self.conv2(out))) # out = self.bn3(self.conv3(out)) # out += self.shortcut(x) # out = F.relu(out) out=x + self.cv2(self.cv1(x)) if self.shortcut else self.cv2(self.cv1(x)) return F.relu(out)
continue:敬请期待
目前提供的代码适配当yolov5的结构中,仅供参考!比如其激活函数的使用和featuremap的空间维度的调整,卷积核的大小等等,设计出work的结构,以及引入新的算子,总之希望大家自己发挥能力去改进和实验,不管是工作和学习的你们和我,都是一种烦恼而快乐的体验!我的代码也只是参考,我目前也在实验和尝试中,有问题互相交流~后面补更swin-transformer。这里上篇还有1个坑,我提到的trick,mosica没有实现。