PS. 代碼部分參考了這篇文章,對代碼做了整合 :https://www.jianshu.com/p/abb7d9b82e2a 理論部分主要參考了花書。
從底層開始複習。在常見的面試中,難免會問及卷積網絡,因而這裏第二部分主要回顧一下卷積的基礎知識。以及如果不幸遇到手寫卷積代碼時,可以有個準備。
1. 卷積神經網絡的基礎概念
卷積神經網絡是一種專門用來處理具有類似網絡結果的數據的神經網絡。至少在網絡的一層中使用卷積運算來替代一般的矩陣乘法運算的神經網絡。
最核心的幾個思想:稀疏交互、參數共享、等變表示(通俗成爲平移不變性)。根本目的說白了就是爲了節省運算時間和空間。那接下來看一下是怎麼實現的。
1.0 卷積
用一張圖展示一下,卷積的計算。element-wise multiply 然後再相加。
看一下動圖,感受一下整個滑動計算的過程。
1.1 稀疏交互(連接數下降到k)
傳統的神經網絡使用矩陣乘法來建立輸入與輸出的連接關係。參數矩陣中每一個單獨的參數都描述了一個輸入單元與一個輸出的單元間的交互。這意味着每一個輸出單元與每一個輸入單元都產生交互(例如全連接層)。而卷積網絡限制連接數,使其具有稀疏交互或者稀疏權重的特徵,使核的大小遠小於輸入的大小來達到。
例如原先有m個輸入和n個輸出,如果矩陣乘法需要 m*n 個參數,時間複雜度爲 O(m*n)。卷積是限制每一個輸出擁有的連接數爲 k,那麼稀疏的連接方法只需要 k*n 個參數,以及運行時間爲 O(k*n)。實際操作中,k比m小几個數量級,而且在機器學習中能取得較好的表現,因而十分有效。
1.2 參數共享(使用同一個連接,下降到了1)
參數共享的定義是指在一個模型的多個函數中使用相同的參數。用於一個輸入的權重也會被綁定在其他的權重上。在卷積神經網絡中,核的每個元素都作用在每一個位置上。卷積運算中的參數共享保證了我們只需要學習一個參數集合,並不需要對每個位置都學習單獨的參數集合。它的前向傳播時間仍舊爲 O(k*n), 但只需要存儲k個參數。大大降低了存儲量。
這個問題容易出現在面試問答中,需要理解其中的原理與動機。
1.3 平移等變
如果一個函數滿足輸入改變,輸出也以同樣的方式改變,它就是等變的。這個特徵可以說是有利有弊吧。先看有利的地方。舉個例子,令 I 爲圖像的亮度函數,g 爲圖像函數的變換函數(把一個圖像函數映射到另一個圖像函數的函數)使得 I' = g(I), 這裏可以假設 g 是使 I 中每個像素右移一位。同樣,如果先對圖像進行右移,再進行卷積操作,和先進行卷積操作,再進行右移,所得到的結果是一樣的。
具體證明,可以使用傅里葉變換(可以參考知乎這篇回答 https://zhuanlan.zhihu.com/p/44769370 ),這裏主要講一下直觀的理解,例如某一卷積核爲了檢測邊緣,在圖像中,各種位置均會有相似邊緣,而權值共享使得在各位置出現的邊緣均能被激活。總體來看,該特徵與位置無關,對位置不敏感。這對於分類來說,十分有效,但對於與位置相關的任務來說,就不那麼合適了,比如目標檢測、語義分割等任務,我們不僅需要類別特徵,更需要位置信息,平移不變性這個特性是我們不想要的,因而會採取一定的措施,彌補位置信息。
2. 關鍵細節的計算
在卷積中,有幾個關鍵參數:feature map, kernel_size, stride, padding。
2.1 feature map 和 kernel_size
這兩個參數很簡單,就是特徵圖的尺寸和卷積核的尺寸。需要注意的就是表示形式,一般我們常用的矩陣爲 c*h*w,c是channel, 即通道數,如果RGB 爲 3通道 c=3,黑白圖像 c=1。在實際訓練中,batch size 往往不爲1, 因此 feature map 更常見的情況是四維,b*c*h*w。另外可能出現的問題是,輸入的形式爲 b*h*w*c。 即要把channel放到最後一個維度。這就要求我們在前期做好數據預處理的工作。kernel_size也是類似,但是只需要 h*w 兩維,具體數目取決於輸入尺寸與相應輸出尺寸。
2.2 stride
stride是步長(步幅)。我們在卷積過程中,有時會希望跳過核中的一些位置來降低計算的開銷,當然同時,提取的特徵就沒有之前那麼好。這一過程,也叫做下采樣,在輸出的每個方向上每間隔 s 個像素進行採樣。可以看下圖,就是以步長爲2在做卷積。
2.3 padding
卷積有一個重要的性質,隱含地對輸入V用0進行填充 (pad),使它加寬。因爲在卷積的過程中,寬度在每一層會縮減,對輸入進行0填充,能幫助我們對輸出大小進行有效控制。padding主要有三種方式:第一種是最簡單的 VALID, 就是不填充。第二種是SAME,進行足夠的零填充保持輸入和輸出具有相同的大小。最後一種是 FULL,它保證足夠多的零填充,使得每個像素在每個方向上剛好被訪問 k 次。更加直觀來說,添加padding爲k-1,即從第一個像素開始能被訪問k次。同樣看下示意圖:
圖2.31 VALID 方式
圖2.32 SAME 方式(添加一定數量的padding,使輸入輸出尺寸相同,具體添加大小根據計算確定)
圖2.33 FULL 方式
2.4 尺寸計算公式
: 輸入圖像的尺寸,例如輸入圖像的高 h 或 寬 w;
: 輸出圖像的尺寸,對應輸入的高或寬;
:卷積核尺寸
: 補零
:步長
因而,根據上述公式,可以計算出不同補零方式的值:
SAME: pad = 0; output = (input-kernel)/stride + 1
VALID : input = output; pad=[(output-1)*stride+kernel-input]/2
FULL: pad = kernel-1; output = (input+kernel-2)/stride + 1
3. 代碼實現
3.1 基於tensorflow的實現
首先,tensorflow中 二維卷積的實現:
tf.nn.conv2d(input, filter, strides, padding, use_cudnn_on_gpu=None, name=None)
input:指需要做卷積的輸入圖像,它要求是一個Tensor,具有[batch, in_height, in_width, in_channels]這樣的shape,具體含義是
[訓練時一個batch的圖片數量, 圖片高度, 圖片寬度, 圖像通道數],注意
這是一個4維的Tensor,要求類型爲float32和float64其中之一
第二個參數filter:相當於CNN中的卷積核,
它要求是一個Tensor,具有
[filter_height, filter_width, in_channels, out_channels]這樣的shape
,具體含義是[卷積核的高度,
],要求類型與參數input相同,有一個地方需要注意,第三維卷積核的寬度,圖像通道數,卷積核個數
,就是參數input的第四維in_channels
第三個參數strides:卷積時在圖像每一維的步長,這是一個一維的向量,長度4
第四個參數padding:string類型的量,只能是"SAME","VALID"其中之一,這個值決定了不同的卷積方式(後面會介紹)
實現:(爲了簡化運算,使用了batch_size =1)
class Conv(object):
def __init__(self,input_data,weights_data,stride,padding='SAME'):
const_input = tf.constant(input_data, tf.float32)
const_weight = tf.constant(weights_data, tf.float32)
input = tf.Variable(const_input, name="input")
input = self.chw2hwc(input)
self.input = tf.expand_dims(input, 0)
weights = tf.Variable(const_weight, name="weights")
weights = self.chw2hwc(weights)
self.weights = tf.expand_dims(weights, 3)
self.stride = stride
self.padding = padding
def get_shape(self,tensor):
[s1,s2,s3] = tensor.get_shape()
s1 = int(s1)
s2 = int(s2)
s3 = int(s3)
return s1,s2,s3
def chw2hwc(self,chw_tensor):
chw_tensor = tf.transpose(chw_tensor,[1,2,0])
return chw_tensor
def hwc2chw(self,hwc_tensor):
hwc_tensor = tf.transpose(hwc_tensor,[2,0,1])
return hwc_tensor
def tf_conv2d(self):
"""
tf.nn.conv2d(input, filter, strides, padding, use_cudnn_on_gpu=None, name=None)
"""
conv = tf.nn.conv2d(self.input, self.weights, strides=self.stride, padding=self.padding)
rs = self.hwc2chw(conv[0])
return rs
代碼主要包括以下幾個部分:
1)對於數據格式進行預處理,使 c*h*w 的數據變爲 h*w*c
2)調用 tf.nn.conv2d
3)將結果轉換成常用 c*h*w 的格式
調用:
# c*h*w shape=[c,h,w]
input_data = [
[[1, 0, 1, 2, 1],
[0, 2, 1, 0, 1],
[1, 1, 0, 2, 0],
[2, 2, 1, 1, 0],
[2, 0, 1, 2, 0]],
[[2, 0, 2, 1, 1],
[0, 1, 0, 0, 2],
[1, 0, 0, 2, 1],
[1, 1, 2, 1, 0],
[1, 0, 1, 1, 1]],
]
# in_c*k*k
weights_data = [
[[1, 0, 1],
[-1, 1, 0],
[0, -1, 0]],
[[-1, 0, 1],
[0, 0, 1],
[1, 1, 1]]
]
conv = Conv(input_data,weights_data,stride=[1,1,1,1])
rs = conv.tf_conv2d()
init = tf.global_variables_initializer()
sess = tf.Session()
sess.run(init)
conv_val = sess.run(rs)
print(conv_val[0])
3.2 基於python 與 numpy 的實現
與上面不同,這裏不需要處理數據格式,但需要對參數進行人爲約束,主要分爲以下幾個方面:
class myConv(object):
def __init__(self,input_data,weight_data,stride,padding='SAME'):
self.input = np.asarray(input_data, np.float32)
self.weights = np.asarray(weights_data, np.float32)
self.stride = stride
self.padding = padding
def compute_conv(self,fm,kernel):
[h,w] = fm.shape
[k,_] = kernel.shape
if self.padding == 'SAME':
pad_h = (self.stride *(h-1) + k - h)//2
pad_w = (self.stride *(w-1) + k - w)//2
rs_h = h
rs_w = w
elif self.padding == 'VALID':
pad_h = 0
pad_w = 0
rs_h = (h-k)/self.stride+1
rs_w = (w-k)/self.stride+1
elif self.padding == 'FULL':
pad_h = k-1
pad_w = k-1
rs_h = (h+k-2)/self.stride+1
rs_w = (w+k-2)/self.stride+1
else:
pad_h = 0
pad_w = 0
rs_h = (h - k) / self.stride + 1
rs_w = (w - k) / self.stride + 1
padding_fm = np.zeros([h+2*pad_h,w+2*pad_w],np.float32)
padding_fm[pad_h:pad_h+h,pad_w:pad_w+w] = fm
rs = np.zeros([rs_h,rs_w],np.float32)
for i in range(rs_h):
for j in range(rs_w):
roi = padding_fm[i*self.stride:(i*self.stride+k),j*self.stride:(j*self.stride+k)]
rs[i][j] = np.sum(roi*kernel)
return rs
def my_conv2d(self):
"""
self.input:c*h*w
self.weights:c*h*w
:return:
"""
[c,h,w] = self.input.shape
[kc,k,_] = self.weights.shape
assert c==kc
outputs = []
for i in range(c):
f_map = self.input[i]
kernel = self.weights[i]
rs = self.compute_conv(f_map,kernel)
if outputs==[]:
outputs = rs
else:
outputs += rs
return outputs
1. 首先根據 pad 的形式,對輸入進行補零操作;
2. 其次根據 input,pad, stride 利用公式計算輸出大小;
3. 然後進行循環進行卷積操作,對不同channel進行卷積,再相加,得到卷積後的結果。具體也可以看上面的動圖,整體實現會比較清楚。
調用:
conv = myConv(input_data,weights_data,1,'SAME')
print(conv.my_conv2d())
最後,兩者輸出的結果相同,說明我們的實現是正確的。