https://zhuanlan.zhihu.com/p/102707081
先上一哈code地址github.com/4uiiurz1/pyt 再給一下論文地址arxiv.org/abs/1703.0621感謝一哈大佬的工作
關於這個論文核心思想這裡就不詳細描述,這裡就簡要的說一下,傳統的卷積操作形狀是規則的,如下式
傳統卷積
簡單地說就是對featuremap上的一塊小區域進行加權平均,再輸出相應值,其形狀是規則的方形。作者覺得這個感受野太規則,不能很好地捕捉特徵,所以在此基礎上對卷積又加了偏置
可變卷積
其實就是在原來的位置上加了一定的偏移
那麼這個偏移是怎麼去學習實現的呢?
我們首先看一下程式碼中這個可變卷積類的初始化函式
可變卷積
開始是對一些常規引數的設定(kernel_size,stride,padding),後續定義了self.conv(最終輸出的卷積層,設定輸入通道數和輸出通道數),self.p_conv(偏置層,學習之前公式(2)中說的偏移量),self.m_conv(權重學習層,這個是後來提出的第二個版本的卷積也就是公式(3)描述的卷積)。register_backward_hook是為了方便檢視這幾層學出來的結果,對網路結構無影響。
self.p_conv和self.m_conv輸入通道由我們自己設定,self.p_conv輸出通道為2*kernel_size*kernel_size代表了卷積核中所有元素的偏移座標(因為同時存在x和y的偏移,所以要乘以2),而self.p_conv輸出通道為(kernel_size*kernel_size)代表了卷積核每個元素的權重。他們的kernel_size為3,stride可以由我們自己設定(這裡涉及之前公式(1,2)對於
的查詢)stride預設值為1
接下來我們分析一下前傳函式
註釋中的N=kernel_size*kernel_size
這是最開始的一部分,首先我們的資料先經過self.p_conv學習出offset(座標偏移量),如果modulation設定為true的話就同時學習出偏置。如之前提到的這兩層的stride都是由自己設定的,所以他們所學習出來的feature map上每個元素恰好與卷積核中心是一一對應的。如下圖
p_conv卷積過程(stride為2,kernel_size為3)
p_conv卷積後的feature map上每個元素恰好與卷積核中心是一一對應
由圖片可以知道透過p_conv後的feature map(上圖全紅的矩形)上每個元素恰好與卷積核中心是一一對應,也就是說透過該feature map的尺寸,卷積核的尺寸,步長可以推算出在卷積過程中卷積核的中心座標。如上圖,我們可知卷積操作次數為6(由feature map的尺寸2*3得出),第一次卷積,卷積核的中心座標為(1,1)(由卷積核尺寸得出),後續所有卷積核中心座標(由第一次中心座標和步長推出)。
接下來透過self._get_p()這個函式獲取所有卷積核中心座標
具體操作如下
首先透過函式get_p_n()生成了卷積的相對座標
生成卷積相對座標
把卷積的中心點定義為原點,其他點座標都相對於原點而言,比如self.kernel_size為3,透過torch.meshgrid生成從(-1,-1)到(1,1)9個座標。將座標的x和y分別儲存,然後再將x,y以(1,2N,1,1)的形式返回,這樣我們就獲取了一個卷積核的所有相對座標。
接下來獲取卷積核在feature map上對應的中心座標,也就是
,程式碼實現如下
獲取feature map上的座標
流程如同之前所說,透過torch.meshgrid生成所有中心座標,透過kernel_size推斷初始座標透過self.stride推斷所有中心座標,(這裡注意一下,程式碼預設torch.arange從1開始,實際上這是kernel_size為3時的情況,我認為程式碼這裡是有那麼一點小瑕疵的,torch.arange應當從kernel_size//2開始,已經向作者提出這個問題還沒得到反饋)
這個_get_p_0的函式和該圖
完全是一一對應的,輸入引數的h,w就是透過p_conv後的feature map的尺寸資訊,接下來講獲取的張量透過repeat()擴充套件成(1,2N,h,w),然後再將我們獲取的相對座標資訊與中心座標相加就獲得了我們卷積核的所有座標,即公式(1)
現在我們看_get_p()中的相關操作就非常好理解了
卷積座標加上之前學習出的offset後就是論文提出的公式(2)也就是加上了偏置後的卷積操作。比如p(在N=0時)p_0就是中心座標,而p_n=(-1,-1),所以此時的p就是卷積核中心座標加上(-1,-1)(即紅色塊左上方的塊)再加上offset。同理可得N=1,N=2...分別代表了一個卷積核上各個元素。
當然這裡仍然有一個問題,我們學習出的量是float型別的,而畫素座標都是整數型別的,所以我們還要用雙線性插值的方法去推算相應的值
獲取座標後進行線性插值
根據線性插值理論我們需要獲取4個整數座標,所以在獲取所有可變卷積核的座標p後,透過floor()獲取不超過本身的最大整數,也就是左上角座標q_lt,同時為了防止偏移量過大移出feature map的範圍透過torch.clamp對卷積核座標做了限制。接下來只要透過左上角座標就可以推出左下角(q_lb),右上角(q_rt),右下角(q_rb)座標。
雙線性插值演算法示意圖
獲取4個點的座標後根據上圖公式可以計算出畫素值前面的係數(即
這部分),程式碼中這4個係數分別為g_lt,g_rb,g_lb,g_rt。現在只獲取了座標值,我們最終木的是獲取相應座標上的值,這裡我們透過self._get_x_q()獲取相應值。
透過座標獲取相應值
輸入x是我們最早輸入的資料x,q則是我們的座標資訊。首先我們獲取q的相關尺寸資訊(b,h,w,2N),再獲取x的w儲存在padding_w中,將x(b,c,h,w)透過view變成(b,c,h*w)。這樣子就把x的座標資訊壓縮在了最後一個維度(h*w),這樣做的目的是為了使用tensor.gather()透過座標來獲取相應值。(這裡注意下q的h,w和x的h,w不一定相同,比如stride不為1的時候)
同樣地,由於(h,w)被壓縮成了(h*w)所以在這個維度上,每過w個元素,就代表了一行,所以我們的座標index=offset_x*w+offset_y(這樣就能在h*w上找到(h,w)的相應座標)同時再把偏移expand()到每一個通道最後返回x_offset(b,c,h,w,N)。(最後輸出x_offset的h,w指的是x的h,w而不是q的)
返回張量(b,c,h,w)
在獲取所有值後我們計算出x_offset,但是x_offset的size是(b,c,h,w,N),我們的目的是將最終的輸出結果的size變成和x一致即(b,c,h,w),所以在最後用了一個reshape的操作。
對(b,c,h,w,N)進行reshape
函式首先獲取了x_offset的所有size資訊,然後以kernel_size為單位進行reshape,因為N=kernel_size*kernel_size,所以我們分兩次進行reshape,第一次先把輸入view成(b,c,h,ks*w,ks),第二次再view將size變成(b,c,h*ks,w*ks) (這部分我實在不知道咋描述了,圖也畫的比較捉急,大家康康,自己悟一下)
最後的reshape操作
之前我們說過N=0,1,2...代表了卷積核上的各個元素,而這個reshape操作就是把這些元素變成了二維空間的形式(h,w)。實際上這樣子做就相當於把每次卷積都單獨拎出來,如下圖
上述reshape操作的實際意義
左圖為卷積操作,第一次卷積時涉及9個元素的運算,右圖把這個9個元素單獨儲存下來。這就是這個reshape函式的實際意義,把每一次卷積操作的所需要的資料資訊單獨儲存(N=ks*ks),當把最後一個維度(N)的資訊透過這種方式保留到(h,w)這兩個維度上後,自然就擴大了ks倍。(右圖開始的3*3塊儲存第一次卷積所需的資料,後面的3*3塊儲存第二次卷積所需的資料)
到這一步我們做的就是對輸入資料x進行可變卷積,並且把每次卷積所需的資料單獨作為一個小塊保留得到了一張大的feature map(b,c,ks*h,ks*w),如同上圖右邊的大feature map,此時reshape出的這張大feature map上的每個ks*ks的小塊代表的就是公式(2)中求和的元素
!!最後再過一個普通卷積層以ks為stride這樣得出的最終輸出size為(b,c,h,w),最後一個卷積層的操作後,所有操作就與與公式(2)
完全符合!這樣可變卷積所有操作就完成了。
至於那個modulation只是對每個元素加了偏置而已並不是很難理解,這裡不展開講
這個reshape操作雖然比較巧妙,但其實空間冗餘比較大,和原文作者的cuda版本記憶體佔用量差了10幾倍。這個是因為在im2col上直接操作可以去掉很冗餘,這個就放到下次再講了