本文是深度學習入門: 基於Python的實現、神經網路與深度學習(NNDL)以及動手學深度學習的讀書筆記。本文將介紹基於Numpy的卷積神經網路(Convolutional Networks,CNN)的實現,本文主要重在理解原理和底層實現。
一、概述
1.1 卷積神經網路(CNN)
卷積神經網路(CNN)是一種具有區域性連線、權重共享和平移不變特性的深層前饋神經網路。
CNN利用了可學習的kernel卷積核(filter濾波器)來提取影像中的模式(區域性和全域性)。傳統影像處理會手動設計卷積核(例如高斯核,來提取邊緣資訊),而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 填充和步幅
應用濾波器的位置間隔稱為步幅(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) 對微小的位置變化具有魯棒性(健壯,容噪)
當輸入資料發生微小偏差時,池化仍會返回相同的結果。因此,池化對輸入資料的微小偏差具有魯棒性
二、CNN實現
卷積層和池化層的實現看起來很複雜,但實際上可通過使用技巧來簡化實現。本節將介紹先im2col技巧,然後再進行卷積層的實現。
2.1 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 卷積層的實現
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 池化層的實現
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的實現
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程式碼實現: