深度學習基礎-基於Numpy的卷積神經網路(CNN)實現

LittileStar發表於2022-06-09

       本文是深度學習入門: 基於Python的實現、神經網路與深度學習(NNDL)以及動手學深度學習的讀書筆記。本文將介紹基於Numpy的卷積神經網路(Convolutional Networks,CNN)的實現,本文主要重在理解原理和底層實現。

一、概述

1.1 卷積神經網路(CNN)

        卷積神經網路(CNN)是一種具有區域性連線、權重共享和平移不變特性的深層前饋神經網路。

        CNN利用了可學習的kernel卷積核(filter濾波器)來提取影像中的模式(區域性和全域性)。傳統影像處理會手動設計卷積核(例如高斯核,來提取邊緣資訊),而CNN則是資料驅動的。

        在數學上,針對一維序列資料,卷積運算可以被理解為一種移動平均(利用歷史訊號對當前時刻資訊進行平滑等處理,換句話說就是考慮當前時刻資訊和以前時刻資訊的按一定比例延遲的疊加)。而二維卷積運算,通常在影像處理中用於平滑訊號達到濾波(例如高斯平滑,削峰填谷)或提取特徵等

        CNN解決了MLP在處理影像時面臨的兩個問題:(1) 引數過多,(2) 缺乏區域性不變性:自然影像中的物體都具有區域性不變性特徵,比如尺度縮放、平移、旋轉等操作不影響其語義資訊.而MLP很難提取這些區域性不變性特徵。換句話說,MLP會忽檢視像的形狀(畫素之間的空間資訊),將影像展開為一維的輸入資料來處理,所以無法利用與形狀相關的資訊,而CNN則不會改變形狀(引入了歸納偏置)。 
        目前的CNN一般是由卷積層、匯聚層和全連線層堆疊而成。其中卷積和匯聚層可以視為用滑動視窗來提取特徵。CNN的滑動視窗帶來的優勢:1)區域性(稀疏)連線,2)引數共享(複用),3)平移不變。接下將介紹CNN的卷積和池化操作。

1.2 卷積層

1.2.1 卷積運算

        令輸入資料(圖片)的形狀為(H, W),其中H為圖片的高height, W為圖片的寬width,卷積核(濾波器Filter)的形狀為(FH, FW),其中FH代表Filter Height,FW代表Filter Width。

        卷積運算將在輸入資料上,以一定間隔(Stride步長或步幅)整體地滑動濾波器的視窗並將濾波器各個位置上的權重值和輸入資料的對應元素相乘。然後,將這個結果儲存到輸出的對應位置。將這個過程在所有位置都做一遍,就能得到卷積運算的輸出。此外,在卷積後,通常會在每個位置的資料上加偏置項。

1.2.2 填充和步幅

        在進行卷積層的處理之前,有時要向輸入資料的周圍填入固定的資料(例如0等)以確保輸出資料(特徵圖,Feature Map)的大小,這稱為填充(padding),是卷積運算中經常會用到的處理。填充也被應用於反摺積中(進行較大範圍的填充,使輸出資料的形狀變大,完成上取樣)。
        “幅度為1的填充”是指用幅度為1畫素的0填充周圍。很容易得知,形狀為的(H, W)輸入資料在進行幅度P的填充後,其形狀將變為(H+2P, W+2P)。

        應用濾波器的位置間隔稱為步幅(stride)。如上圖所示,之前的例子中步幅S都是1,如果將步幅S設為2,應用濾波器的視窗的間隔變為2個元素。綜上,增大步幅後,輸出大小會變小。而增大填充後,輸出大小會變大。對於填充和步幅,輸出大小的關係如下式所示:

1.2.3 通道

        之前的卷積運算的例子都是以有高、長方向的2維形狀為物件的。但是,影像是3維資料,除了高、長方向之外,還需要處理通道方向(例如,RGB)。上圖以3通道的資料為例,展示了卷積運算的結果。和處理2維資料時相比,可以發現縱深方向(通道方向)上特徵圖增加了。通道方向上有多個特徵圖時,可以按通道進行輸入資料和濾波器的卷積運算,並將結果相加,從而得到輸出不同通道的Kernel大小應該一致

       為了便於理解3維資料的卷積運算,我們這裡將資料和濾波器結合長方體的方塊來考慮。方塊是上圖所示的3維長方體。把3維資料表示為多維陣列時,書寫順序為(channel, height, width)。比如,通道數為C、高度為H、長度為W的資料的形狀可以寫成(C, H, W)。濾波器也一樣,要對應順序書寫。比如,通道數為C、濾波器高度為FH、長度為FW時,可以寫成(C, FH, FW)。若使用FN個濾波器,輸出特徵圖也將有FN個。如果將這FN個特徵圖彙集在一起,就得到了形狀為(FN, OH, OW)的方塊

       卷積運算中(和全連線層一樣)存在偏置。如果進一步追加偏置的加法運算處理,要對濾波器的輸出結果(FN, OH, OW)按通道加上相同的偏置值。

        當前只是一個輸入(單個3通道影像),還可以輸入N個影像,構成一個Batch,以矩陣乘法加速。

1.3 池化層

        池化層(匯聚層,Pooling Layer)也叫子取樣層(Subsampling Layer),其作用是進行特徵選擇,降低特徵數量,從而減少引數數量具體來說,池化是縮小高、長方向上的空間的運算(多變少)。在卷積層之後加上一個匯聚層,可以降低特徵維數,避免過擬合。

        池化層的特性:

     1)沒有要學習的引數

          池化層和卷積層不同,沒有要學習的引數。池化只是從目標區域中取最大值(或者平均值),所以不存在要學習的引數

     2)  通道數不發生變化

          經過池化運算,輸入資料和輸出資料的通道數不會發生變化

     3)  對微小的位置變化具有魯棒性(健壯,容噪)

        當輸入資料發生微小偏差時,池化仍會返回相同的結果。因此,池化對輸入資料的微小偏差具有魯棒性

       目前,卷積網路的整體結構趨向於使用更小的卷積核(比如 1 × 1 和 3 × 3)以及更深的結構(比如層數大於 50).此外,由於卷積的操作性越來越靈活(比如不同的步長),匯聚層的作用也變得越來越小,因此目前比較流行的卷積網路中,匯聚層的比例正在逐漸降低,趨向於全卷積網路。

二、CNN實現

       卷積層和池化層的實現看起來很複雜,但實際上可通過使用技巧來簡化實現。本節將介紹先im2col技巧,然後再進行卷積層的實現。

2.1 Im2col技巧

       如前所述,CNN中各層間傳遞的資料是4維資料。所謂4維資料,比如資料的形狀是(10, 1, 28, 28),則它對應10個高為28、長為28、通道為1的資料。對於這樣的4維資料此卷積運算的實現看上去會很複雜,但是通過使用下面要介紹的im2col(Image to column)技巧,問題將變得很簡單。
        如果老老實實地實現卷積運算需要多重迴圈,這樣做不僅實現複雜且速度較慢。為避免這一問題,我們引入了im2col函式。im2col是一個將輸入資料展開以適合濾波器(權重)的函式。如上圖所示,對3維的輸入資料應用im2col後,im2col會把輸入資料展開以適合濾波器(權重)。具體地說,對於輸入資料,將應用濾波器的區域(3維方塊)橫向展開為1列(轉置後為一行)。im2col會在所有應用濾波器的地方進行展開處理。
        上圖為便於觀察,將步幅設定得很大,以使濾波器的應用區域不重疊。而在實際的卷積運算中,濾波器的應用區域幾乎都是重疊的在濾波器的應用區域重疊的情況下,使用im2col展開後,展開後的元素個數會多於原方塊的因此,使用im2col比普通實現消耗更多記憶體。但是,彙總成一個大矩陣可減少計算耗時

       實際上,im2col函式就是將輸入資料中所有濾波器需要處理的區域性資料(即滑動視窗對應的資料)事先拿出來,展開為矩陣形式(每一行對應一個資料),然後將卷積核也展開為列向量隨後就可將兩者做矩陣乘法運算來加速卷積操作(本質上,卷積核和對應資料的卷積運算就是在做內積)。這和全連線層的Affine層進行的處理基本相同濾波器本質上仍是權重矩陣

       此外,對於大小相同的一批資料,由於卷積層的濾波器沒變所以只需將資料按行拼接,計算後再reshape即可。im2col的實現如下,就是按卷積核來滑動視窗預先取出並展開資料:

 1 def im2col(input_data, filter_h, filter_w, stride=1, pad=0):
 2     """
 3     把對應卷積核的資料部分拿出來,reshape為向量,進一步拼為矩陣
 4     Parameters:
 5         input_data (tensor): 由(資料量, 通道, 高, 寬)的4維張量構成的輸入資料
 6         filter_h (int): 濾波器的高
 7         filter_w (int): 濾波器的寬
 8         stride (int): 步幅
 9         pad (int): 填充
10 
11     Returns:
12         col (tensor): 2維陣列
13     """
14     N, C, H, W = input_data.shape
15     out_h = (H + 2*pad - filter_h)//stride + 1
16     out_w = (W + 2*pad - filter_w)//stride + 1
17 
18     img = np.pad(input_data, [(0,0), (0,0), (pad, pad), (pad, pad)], 'constant')
19     col = np.zeros((N, C, filter_h, filter_w, out_h, out_w))
20 
21     for y in range(filter_h):
22         y_max = y + stride*out_h
23         for x in range(filter_w):
24             x_max = x + stride*out_w
25             col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride]
26 
27     col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N*out_h*out_w, -1)
28     return col

        此外,給出其逆操作,以便實現梯度反向傳播:

 1 def col2im(col, input_shape, filter_h, filter_w, stride=1, pad=0):
 2     """
 3     im2col的逆處理,將展開後的資料還原回原始輸入資料形式
 4     Parameters:
 5         col (tensor): 2維陣列
 6         input_shape (int): 輸入資料的形狀(例:(10, 1, 28, 28))
 7         filter_h (int): 濾波器的高
 8         filter_w (int): 濾波器的寬
 9         stride (int): 步幅
10         pad (int): 填充
11     Returns:
12     """
13     N, C, H, W = input_shape
14     out_h = (H + 2*pad - filter_h)//stride + 1
15     out_w = (W + 2*pad - filter_w)//stride + 1
16     col = col.reshape(N, out_h, out_w, C, filter_h, filter_w).transpose(0, 3, 4, 5, 1, 2)
17     img = np.zeros((N, C, H + 2*pad + stride - 1, W + 2*pad + stride - 1))
18     
19     for y in range(filter_h):
20         y_max = y + stride*out_h
21         for x in range(filter_w):
22             x_max = x + stride*out_w
23             img[:, :, y:y_max:stride, x:x_max:stride] += col[:, :, y, x, :, :]
24     return img[:, :, pad:H + pad, pad:W + pad]

2.2 卷積層的實現

       卷積層將被實現為名為Convolution的類。卷積層的初始化方法將濾波器(權重)、偏置、步幅、填充作為引數接收。濾波器是 (FN, C, FH, FW)的 4 維形狀。另外,FN、C、FH、FW分別是 FilterNumber(濾波器數量)、Channel、Filter Height、Filter Width的縮寫。
       在forward的實現中,先用im2col展開輸入資料,並用reshape將濾波器展開為2維陣列。然後,計算展開後的矩陣的乘積。最後會將輸出大小轉換為合適的形狀。通過使用im2col進行展開,基本上可以像實現全連線層的Affine層一樣來實現。
       接下來是卷積層的反向傳播的實現,因為和Affine層的實現有很多共通的地方,所以就不再介紹。但需注意的是,在進行卷積層的反向傳播時,必須進行im2col的逆處理(卷積核引數的梯度容易獲取,關鍵是如何獲取輸入資料關於損失函式的梯度,以便回傳除了使用col2im這一點,卷積層的反向傳播和Affine層的實現方式都一樣。
 1 class Convolution:
 2     def __init__(self, W, b, stride=1, pad=0):
 3         # 卷積層的初始化方法將濾波器(權重)、偏置、步幅、填充作為引數
 4         # 濾波器是 (FN, C, FH, FW), Filter Number濾波器數量、Channel、Filter Height、Filter Width
 5         self.W = W  # 每一個Filter(原本為3維tensor權重)將reshape為權重向量 [(C*FH*FW) X 1], 列向量
 6         self.b = b  # C一個Filter將拼接為為卷積核權重矩陣 [(C*FH*FW) X FN]
 7         self.stride = stride
 8         self.pad = pad
 9         # 中間資料(backward時使用)
10         self.x = None   
11         self.col = None
12         self.col_W = None
13         # 權重和偏置引數的梯度
14         self.dW = None
15         self.db = None
16 
17     def forward(self, x):
18         FN, C, FH, FW = self.W.shape
19         N, C, H, W = x.shape
20         out_h = 1 + int((H + 2*self.pad - FH) / self.stride)
21         out_w = 1 + int((W + 2*self.pad - FW) / self.stride)
22         # 用im2col展開輸入資料x,並用reshape將濾波器權重展開為2維陣列。
23         col = im2col(x, FH, FW, self.stride, self.pad)
24         col_W = self.W.reshape(FN, -1).T
25         out = np.dot(col, col_W) + self.b  # 計算展開後的矩陣的乘積
26         out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)  # (N, C, H, W)
27 
28         self.x = x
29         self.col = col
30         self.col_W = col_W
31         return out
32 
33     def backward(self, dout):
34         FN, C, FH, FW = self.W.shape
35         dout = dout.transpose(0,2,3,1).reshape(-1, FN)
36         self.db = np.sum(dout, axis=0)
37         self.dW = np.dot(self.col.T, dout)  # 類似於Affine Transformation的引數梯度的計算
38         self.dW = self.dW.transpose(1, 0).reshape(FN, C, FH, FW)
39 
40         dcol = np.dot(dout, self.col_W.T)
41         dx = col2im(dcol, self.x.shape, FH, FW, self.stride, self.pad)
42         return dx # 回傳的梯度

2.3 池化層的實現

       池化層的實現和卷積層相同,都使用im2col展開輸入資料。不過,池化的情況下,在通道方向上是獨立的。具體地講,如圖上所示,池化的應用區域按通道單獨展開。像這樣展開之後,只需對展開的矩陣求各行的最大值,並轉換為合適的形狀即可(池化無引數)池化操作的反向傳播計算過程和Relu非常類似,它僅僅回傳池化後的元素的梯度。
 1 class Pooling:
 2     def __init__(self, pool_h, pool_w, stride=1, pad=0):
 3         # 池化層的實現和卷積層相同,都使用im2col展開輸入資料
 4         self.pool_h = pool_h
 5         self.pool_w = pool_w
 6         self.stride = stride
 7         self.pad = pad
 8         self.x = None
 9         self.arg_max = None
10 
11     def forward(self, x):
12         N, C, H, W = x.shape
13         out_h = int(1 + (H - self.pool_h) / self.stride)
14         out_w = int(1 + (W - self.pool_w) / self.stride)
15         col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)
16         col = col.reshape(-1, self.pool_h*self.pool_w)
17         # X展開之後,只需對展開的矩陣求各行的最大值,並轉換為合適的形狀
18         arg_max = np.argmax(col, axis=1)
19         out = np.max(col, axis=1)
20         out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)
21 
22         self.x = x
23         self.arg_max = arg_max  # 僅對池化後的元素求梯度(相當於一個特殊的Relu,mask掉了其他元素)
24         return out
25 
26     def backward(self, dout):
27         dout = dout.transpose(0, 2, 3, 1)
28         pool_size = self.pool_h * self.pool_w
29         dmax = np.zeros((dout.size, pool_size)) # 只將dout賦予那些池化後的得到元素的位置,其餘元素梯度置為0
30         dmax[np.arange(self.arg_max.size), self.arg_max.flatten()] = dout.flatten()
31         dmax = dmax.reshape(dout.shape + (pool_size,)) 
32         
33         dcol = dmax.reshape(dmax.shape[0] * dmax.shape[1] * dmax.shape[2], -1)
34         dx = col2im(dcol, self.x.shape, self.pool_h, self.pool_w, self.stride, self.pad)
35         return dx # 將column重新組織層圖片輸入形狀,並回傳梯度

 2.4 CNN的實現

        簡單CNN分類網路由“Convolution - ReLU - Pooling -Affine -ReLU - Affine - Softmax”的構成,它被實現為SimpleConvNet。可以堆疊多個Convolution、Relu、Pooling等元件實現更復雜的卷積網路。
  1 class SimpleConvNet:
  2     """簡單的ConvNet: conv - relu - pool - affine - relu - affine - softmax
  3     Parameters:
  4         input_size : 輸入大小(MNIST的情況下為784)
  5         hidden_size_list : 隱藏層的神經元數量的列表(e.g. [100, 100, 100])
  6         output_size : 輸出大小(MNIST的情況下為10)
  7         activation : 'relu' or 'sigmoid'
  8         weight_init_std : 指定權重的標準差(e.g. 0.01)
  9             指定'relu'或'he'的情況下設定“He的初始值”
 10             指定'sigmoid'或'xavier'的情況下設定“Xavier的初始值”
 11     """
 12     def __init__(self, input_dim=(1, 28, 28), 
 13                  conv_param={'filter_num': 30, 'filter_size': 5, 'pad': 0, 'stride': 1},
 14                  hidden_size=100, output_size=10, weight_init_std=0.01):
 15         filter_num = conv_param['filter_num']
 16         filter_size = conv_param['filter_size']
 17         filter_pad = conv_param['pad']
 18         filter_stride = conv_param['stride']
 19         input_size = input_dim[1]
 20         conv_output_size = (input_size - filter_size + 2 * filter_pad) / filter_stride + 1
 21         pool_output_size = int(filter_num * (conv_output_size / 2) * (conv_output_size / 2))
 22 
 23         # 初始化權重
 24         self.params = {}
 25         self.params['W1'] = weight_init_std * \
 26                             np.random.randn(filter_num, input_dim[0], filter_size, filter_size)
 27         self.params['b1'] = np.zeros(filter_num)
 28         self.params['W2'] = weight_init_std * \
 29                             np.random.randn(pool_output_size, hidden_size)
 30         self.params['b2'] = np.zeros(hidden_size)
 31         self.params['W3'] = weight_init_std * \
 32                             np.random.randn(hidden_size, output_size)
 33         self.params['b3'] = np.zeros(output_size)
 34 
 35         # 生成層
 36         self.layers = OrderedDict()
 37         self.layers['Conv1'] = Convolution(self.params['W1'], self.params['b1'],
 38                                            conv_param['stride'], conv_param['pad'])
 39         self.layers['Relu1'] = Relu()
 40         self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
 41         self.layers['Affine1'] = Affine(self.params['W2'], self.params['b2'])
 42         self.layers['Relu2'] = Relu()
 43         self.layers['Affine2'] = Affine(self.params['W3'], self.params['b3'])
 44 
 45         self.last_layer = SoftmaxWithLoss()
 46 
 47     def predict(self, x):
 48         for layer in self.layers.values():
 49             x = layer.forward(x)
 50 
 51         return x
 52 
 53     def loss(self, x, t):
 54         """求損失函式。引數x是輸入資料、t是教師標籤
 55         """
 56         y = self.predict(x)
 57         return self.last_layer.forward(y, t)
 58 
 59     def gradient(self, x, t):
 60         """求梯度(誤差反向傳播法)
 61 
 62         Parameters:
 63             x : 輸入資料
 64             t : 教師標籤
 65 
 66         Returns:
 67             具有各層的梯度的字典變數
 68             grads['W1']、grads['W2']、...是各層的權重
 69             grads['b1']、grads['b2']、...是各層的偏置
 70         """
 71         # forward
 72         self.loss(x, t)
 73 
 74         # backward
 75         dout = 1
 76         dout = self.last_layer.backward(dout)
 77 
 78         layers = list(self.layers.values())
 79         layers.reverse()
 80         for layer in layers:
 81             dout = layer.backward(dout)
 82 
 83         # 設定
 84         grads = {}
 85         grads['W1'], grads['b1'] = self.layers['Conv1'].dW, self.layers['Conv1'].db
 86         grads['W2'], grads['b2'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
 87         grads['W3'], grads['b3'] = self.layers['Affine2'].dW, self.layers['Affine2'].db
 88         return grads
 89         
 90     def save_params(self, file_name="params.pkl"):
 91         params = {}
 92         for key, val in self.params.items():
 93             params[key] = val
 94         with open(file_name, 'wb') as f:
 95             pickle.dump(params, f)
 96 
 97     def load_params(self, file_name="params.pkl"):
 98         with open(file_name, 'rb') as f:
 99             params = pickle.load(f)
100         for key, val in params.items():
101             self.params[key] = val
102 
103         for i, key in enumerate(['Conv1', 'Affine1', 'Affine2']):
104             self.layers[key].W = self.params['W' + str(i+1)]
105             self.layers[key].b = self.params['b' + str(i+1)]

三、典型的深度CNN

       LeNet-5是由Yann LeCun提出的第一個也是非常經典的卷積神經網路模型。LeNet-5的網路結構如上圖所示。LeNet-5共有7層,接受輸入影像大小為32 × 32 = 1 024,輸出對應10個類別的得分。LeNet中使用了sigmoid函式,而現在的CNN中主要使用ReLU函式。

        AlexNet堆疊了多個卷積層和池化層,最後經由全連線層輸出結果。雖然結構上AlexNet和LeNet沒有大的不同,但有以下幾點差異。它的啟用函式用了ReLU,應用了Dropout,並使用了區域性正規化的LRN(Local Response Normalization)層來避免過擬合。

        上述兩個網路都可以用Numpy來實現,不過為了實現方便和避免重複造低效的輪子,可以直接用Pytorch或Tensorflow等框架來實現或使用現成的網路。例如,  LeNet-5可以直接用如下幾行pytorch程式碼實現:

       至於更深的卷積神經網路, 就不在詳細展開。它們往往具有如下特點:
               • 引入殘差或跳連線
               • 啟用函式是ReLU
               • 基於小型濾波器的卷積層,例如3×3
               • 使用He初始值作為權重初始值
               • 使用BatchNormalizaiton歸一化操作
               • 全連線層的後面使用Dropout層
               • 基於Adam的最優化
 
 

相關文章