手把手使用numpy搭建卷積神經網絡
主要內容來自DeepLearning.AI的卷積神經網絡
本文使用numpy實現卷積層和池化層,包括前向傳播和反向傳播過程。
在具體描述之前,先對使用符號做定義。
- 上標[I]表示神經網絡的第Ith層。
- \(a^{[4]}\)表示第4層神經網絡的激活值;\(W^{[5]}\)和\(b^{[5]}\)表示神經網絡第5層的參數;
- 上標(i)表示第i個數據樣本
- \(x^{(i)}\)表示第i個輸入樣本
- 下標i表示向量的第i個元素
- \(a_i^{[l]}\)表示神經網絡第l層的激活向量的第i個元素。
- \(n_H,n_W,n_C\)表示當前層神經網絡的高度、寬度和通道數。
- \(n_{H_{prev}},n_{W_{prev}},n_{C_{prev}}\)表示上一層神經網絡的高度、寬度和通道數。
1. 導入包
首先,導入需要使用的工具包。
import numpy as np
import h5py
import matplotlib.pyplot as plt
%matplotlib inline
plt.rcParams['figure.figsize'] = (5.0, 4.0) # set default size of plots
plt.rcParams['image.interpolation'] = 'nearest'
plt.rcParams['image.cmap'] = 'gray'
%load_ext autoreload
%autoreload 2
np.random.seed(1) # 指定隨機數種子
2. 大綱
本文要實現的卷積神經網絡的幾個網絡塊,每個網絡包含的功能模塊如下。
-
卷積函數Convolution
- 0填充邊界
- 卷積窗口
- 卷積運算前向傳播
- 卷積運算反向傳播
-
池化函數Pooling
- 池化函數前向傳播
- 掩碼創建
- 值分配
- 池化的反向傳播
在每個前向傳播的函數中,在參數更新時會有一個反向傳播過程;此外,在前向傳播過程會緩存一個參數,用於在反向傳播過程中計算梯度。
3. 卷積神經網絡
盡管當下存在很多深度學習框架使得卷積網絡使用更為便捷,但是卷積網絡在深度學習中仍然是一個難以理解的運算。卷積層能將輸入轉換為具有不同維度的輸出,如下圖所示。
接下來,我們自己實現卷積運算。首先,實現兩個輔助函數:0填充邊界和計算卷積。
3.1 0值邊界填充
0值邊界填充顧名思義,使用0填充在圖片的邊界周圍。
使用邊界填充的優點:
- 可以保證使用上一層的輸出結果經過卷積運算后,其高度和寬度不會發生變化。這個特性對於構建深層網絡非常重要,否則隨着網絡深度的增加,計算結果會逐步縮水,直至降為1。一種特殊的的“Same”卷積,可以保證計算結果的寬度和高度不發生變化
- 可以在圖像邊界保留更多的信息。不適用填充的情況下,圖像的邊緣像素對下一層結果的影響小(圖像的中間像素使用滑動窗口時會遍歷多次,而邊界元素則比較少,有可能只使用1次)。
# GRADED FUNCTION: zero_pad
def zero_pad(X, pad):
"""
把數據集X的圖像邊界用0值填充。填充情況發生在每張圖像的寬度和高度上。
參數:
X -- 圖像數據集 (m, n_H, n_W, n_C),分別表示樣本數、圖像高度、圖像寬度、通道數
pad -- 整數,每個圖像在垂直和水平方向上的填充量
返回:
X_pad -- 填充后的圖像數據集 (m, n_H + 2*pad, n_W + 2*pad, n_C)
"""
# X數據集有4個維度,填充發生在第2個維度和第三個維度上;填充方式為0值填充
X_pad = np.pad(X, (
(0, 0),# 樣本數維度,不填充
(pad, pad), #n_H維度,上下各填充pad個像素
(pad, pad), #n_W維度,上下各填充pad個像素
(0, 0)), #n_C維度,不填充
mode='constant', constant_values = (0, 0))
return X_pad
我們來測試一下:
np.random.seed(1)
x = np.random.randn(4, 3, 3, 2)
x_pad = zero_pad(x, 2)
print ("x.shape =\n", x.shape)
print ("x_pad.shape =\n", x_pad.shape)
print ("x[1,1] =\n", x[1,1])
print ("x_pad[1,1] =\n", x_pad[1,1])
fig, axarr = plt.subplots(1, 2)
axarr[0].set_title('x')
axarr[0].imshow(x[0,:,:,0])
axarr[1].set_title('x_pad')
axarr[1].imshow(x_pad[0,:,:,0])
測試結果:
x.shape = (4, 3, 3, 2)
x_pad.shape = (4, 7, 7, 2)
x[1,1] = [[ 0.90085595 -0.68372786]
[-0.12289023 -0.93576943]
[-0.26788808 0.53035547]]
x_pad[1,1] = [[ 0. 0.]
[ 0. 0.]
[ 0. 0.]
[ 0. 0.]
[ 0. 0.]
[ 0. 0.]
[ 0. 0.]]
3.2 單步卷積(單個滑動窗口計算過程)
實現一個單步卷積的計算過程,將卷積核和輸入的一個窗口片進行計算,之后使用這個函數實現真正的卷積運算。卷積運算包括:
- 接受一個輸入
- 將卷積核和輸入的每個窗口分片進行單步卷積計算
- 輸出計算結果(維度通常與輸入維度不同)
我們看一個卷積運算過程:
在計算機視覺應用中,左側矩陣的每個值代表一個像素,我們使用一個3x3的卷積核和輸入圖像對應窗口片進行element-wise相乘然后求和,最后加上bias完成一個卷積的單步運算。
# GRADED FUNCTION: conv_single_step
def conv_single_step(a_slice_prev, W, b):
"""
使用卷積核與上一層的輸出結果的一個分片進行卷積運算
參數:
a_slice_prev -- 輸入分片, (f, f, n_C_prev)
W -- 權重參數,包含在一個矩陣中 (f, f, n_C_prev)
b -- 偏置參數,包含在一個矩陣中 (1, 1, 1)
返回:
Z -- 一個實數,表示在輸入數據X的分片a_slice_prev和滑動窗口(W,b)的卷積計算結果
"""
# 逐元素相乘,結果維度為(f,f,n_C_prev)
s = np.multiply(a_slice_prev, W)
# 求和
Z = np.sum(s)
# 加上偏置參數b,使用float將(1,1,1)變為一個實數
Z = Z + float(b)
return Z
我們測試一下代碼:
np.random.seed(1)
a_slice_prev = np.random.randn(4, 4, 3)
W = np.random.randn(4, 4, 3)
b = np.random.randn(1, 1, 1)
Z = conv_single_step(a_slice_prev, W, b)
print("Z =", Z)
輸出結果為:
Z = -6.99908945068
3.3 卷積層的前向傳播
在卷積層的前向傳播過程中會使用多個卷積核,每個卷積核與輸入圖片計算得到一個2D矩陣,然后將多個卷積核的計算結果堆疊起來形成最終輸出。
我們需要實現一個卷積函數,這個函數接收上一層的輸出結果A_prev,然后使用大小為fxf卷積核進行卷積運算。這里的輸入A_prev為若干張圖片,同時卷積核的數量也可能有多個。
為了方便理解卷積的計算過程,我們這里使用for循環進行描述。
提示:
-
如果需要在(5,5,3)的矩陣的左上角截取一個2x2的分片,可以使用:
a_slice_prev = a_prev[0:2,0:2,:]
;值得注意的是分片結果具有3個維度(2,2,n_C_prev) -
如果想自定義分片,需要明確分片的位置,可以使用vert_start, vert_end, horiz_start 和horiz_end四個值來確定。如圖
-
卷積層計算結果的維度計算,可以使用公式確定:
卷積層代碼如下:
# GRADED FUNCTION: conv_forward
def conv_forward(A_prev, W, b, hparameters):
"""
卷積層的前向傳播
參數:
A_prev --- 上一層網絡的輸出結果,(m, n_H_prev, n_W_prev, n_C_prev),
W -- 權重參數,指這一層的卷積核參數 (f, f, n_C_prev, n_C),n_C個大小為(f,f,n_C_prev)的卷積核
b -- 偏置參數 (1, 1, 1, n_C)
hparameters -- 超參數字典,包含 "stride" and "pad"
返回:
Z -- 卷積計算結果,維度為 (m, n_H, n_W, n_C)
cache -- 緩存卷積層反向傳播計算需要的數據
"""
# 輸出參數的維度,包含m個樣from W's shape (≈1 line)
(m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
# 權重參數
(f, f, n_C_prev, n_C) = W.shape
# 獲取本層的超參數:步長和填充寬度
stride = hparameters['stride']
pad = hparameters['pad']
# 計算輸出結果的維度
# 使用int函數代替np.floor向下取整
n_H = int((n_H_prev + 2 * pad - f)/stride) + 1
n_W = int((n_W_prev + 2 * pad - f)/stride) + 1
# 聲明輸出結果
Z = np.zeros((m, n_H, n_W, n_C))
# 1. 對輸出數據A_prev進行0值邊界填充
A_prev_pad = zero_pad(A_prev, pad)
for i in range(m): # 依次遍歷每個樣本
a_prev_pad = A_prev_pad[i] # 獲取當前樣本
for h in range(n_H): # 在輸出結果的垂直方向上循環
for w in range(n_W):#在輸出結果的水平方向上循環
# 確定分片邊界
vert_start = h * stride
vert_end = vert_start + f
horiz_start = w * stride
horiz_end = horiz_start + f
for c in range(n_C): # 遍歷輸出的通道
# 在輸入數據上獲取當前切片,結果是3D
a_slice_prev = a_prev_pad[vert_start:vert_end,horiz_start:horiz_end,:]
# 獲取當前的卷積核參數
weights = W[:,:,:, c]
biases = b[:,:,:, c]
# 輸出結果當前位置的計算值,使用單步卷積函數
Z[i, h, w, c] = conv_single_step(a_slice_prev, weights, biases)
assert(Z.shape == (m, n_H, n_W, n_C))
# 將本層數據緩存,方便反向傳播時使用
cache = (A_prev, W, b, hparameters)
return Z, cache
我們來測試一下:
np.random.seed(1)
A_prev = np.random.randn(10,5,7,4)
W = np.random.randn(3,3,4,8)
b = np.random.randn(1,1,1,8)
hparameters = {"pad" : 1, "stride": 2}
Z, cache_conv = conv_forward(A_prev, W, b, hparameters)
print("Z's mean =\n", np.mean(Z))
print("Z[3,2,1] =\n", Z[3,2,1])
print("cache_conv[0][1][2][3] =\n", cache_conv[0][1][2][3])
輸出值為:
Z's mean = 0.692360880758
Z[3,2,1] = [ -1.28912231 2.27650251 6.61941931 0.95527176 8.25132576
2.31329639 13.00689405 2.34576051]
cache_conv[0][1][2][3] = [-1.1191154 1.9560789 -0.3264995 -1.34267579]
最后,卷積層應該包含一個激活函數,我們可以添加以下代碼完成:
# 獲取輸出個某個單元值
Z[i, h, w, c] = ...
# 使用激活函數
A[i, h, w, c] = activation(Z[i, h, w, c])
4. 池化層
池化層可以用於縮小輸入數據的高度和寬度。池化層有利於簡化計算,同時也有助於對輸入數據的位置更加穩定。池化層有兩種類型:
- 最大池化:在輸入數據上用一個(f, f)的窗口進行滑動,在輸出中保存窗口的最大值
- 平均池化:在輸入數據上用一個(f, f)的窗口進行滑動,在輸出中保存窗口的平均值
池化層沒有參數需要訓練,但是它們有像窗口大小f的超參數,它指定了窗口的大小為f x f,這個窗口用於計算最大值和平均值。
4.1 前向傳播
我們這里在同一個函數中實現最大池化和平均池化。
提示:
池化層沒有填充項,輸出結果的維度和輸入數據的維度相關,計算公式為:
實現代碼如下:
def pool_forward(A_prev, hparameters, mode = "max"):
"""
池化層的前向傳播
參數:
A_prev -- 輸入數據,維度為 (m, n_H_prev, n_W_prev, n_C_prev)
hparameters -- 超參數字典,包含 "f" and "stride"
mode -- string;表示池化方式, ("max" or "average")
返回:
A -- 輸出結果,維度為 (m, n_H, n_W, n_C)
cache -- 緩存數據,用於池化層的反向傳播, 緩存輸入數據和池化層的超參數(f、stride)
"""
(m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
f = hparameters["f"]
stride = hparameters["stride"]
# 計算輸出數據的維度
n_H = int(1 + (n_H_prev - f) / stride)
n_W = int(1 + (n_W_prev - f) / stride)
n_C = n_C_prev
# 定義輸出結果
A = np.zeros((m, n_H, n_W, n_C))
# 逐個計算,對A的元素進行賦值
for i in range(m): # 遍歷樣本
for h in range(n_H):# 遍歷n_H維度
# 確定分片垂直方向上的位置
vert_start = h * stride
vert_end =vert_start + f
for w in range(n_W):# 遍歷n_W維度
# 確定分片水平方向上的位置
horiz_start = w * stride
horiz_end = horiz_start + f
for c in range (n_C):# 遍歷通道
# 確定當前樣本上的分片
a_prev_slice = A_prev[i, vert_start:vert_end, horiz_start:horiz_end, c]
# 根據池化方式,計算當前分片上的池化結果
if mode == "max":# 最大池化
A[i, h, w, c] = np.max(a_prev_slice)
elif mode == "average":# 平均池化
A[i, h, w, c] = np.mean(a_prev_slice)
# 將池化層的輸入和超參數緩存
cache = (A_prev, hparameters)
# 確保輸出結果維度正確
assert(A.shape == (m, n_H, n_W, n_C))
return A, cache
5. 反向傳播
在深度學習框架中,你只需要實現前向傳播,框架可以自動實現反向傳播過程,因此大多數深度學習工程師不需要關注反向傳播過程。卷積層的反向傳播過程比較復雜。
在之前的我們實現全連接神經網絡,我們使用反向傳播計算損失函數的偏導數進而對參數進行更新。類似的,在卷積神經網絡中我們也可以計算損失函數對參數的梯度進而進行參數更新。
5.1 卷積層的反向傳播
我們這里實現卷積層的反向傳播過程。
5.1.1 計算dA
下面是計算dA的公式:
其中\(W_c\)表示一個卷積核,\(dZ_{hw}\)是損失函數對卷積層的輸出Z的第h行第w列的梯度。值得注意的是,每次更新dA時都會用相同的\(W_c\)乘以不同的\(dZ\). 因為卷積層在前向傳播過程中,同一個卷積核會和輸入數據的每一個分片逐元素相乘然后求和。所以在反向傳播計算dA時,需要把所有a_slice的梯度都加進來。我們可以在循環中添加代碼:
da_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] += W[:,:,:,c] * dZ[i, h, w, c]
5.1.2 計算dW
計算\(dW_c\)的公式(Wc是一個卷積核):
其中,\(a_{slice}\)表示\(Z_{hw}\)對應的輸入分片。因為我們使用卷積核作為一個窗口對輸入數據進行切片計算卷積,滑動了多少次就對應多少個分片,也就需要累加多少梯度數據。在代碼中我們只需要添加一行代碼:
dW[:,:,:,c] += a_slice * dZ[i, h, w, c]
5.1.3 計算db
損失函數對當前卷積層的參數b的梯度db的計算公式:
和之前的神經網絡類似,db是由dZ累加計算而成。只需要將conv的輸出Z的所有梯度累加即可。在循環中添加一行代碼:
db[:,:,:,c] += dZ[i, h, w, c]
5.1.4 函數實現
def conv_backward(dZ, cache):
"""
實現卷積層的反向傳播過程
參數:
dZ -- 損失函數對卷積層輸出Z的梯度, 維度和Z相同(m, n_H, n_W, n_C)
cache -- 卷積層前向傳播過程中緩存的數據
返回:
dA_prev -- 損失函數對卷積層輸入A_prev的梯度,其維度和A_prev相同(m, n_H_prev, n_W_prev, n_C_prev)
dW -- 損失函數對卷積層權重參數的梯度,其維度和W相同(f, f, n_C_prev, n_C)
db -- 損失函數對卷積層偏置參數b的梯度,其維度和b相同(1, 1, 1, n_C)
"""
# 得到前向傳播中的緩存數據,方便后續使用
(A_prev, W, b, hparameters) = cache
# 輸入數據的維度
(m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
# Retrieve dimensions from W's shape
(f, f, n_C_prev, n_C) = W.shape
# Retrieve information from "hparameters"
stride = hparameters['stride']
pad = hparameters['pad']
(m, n_H, n_W, n_C) = dZ.shape
# 對輸出結果進行初始化
dA_prev = np.zeros_like(A_prev)
dW = np.zeros_like(W)
db = np.zeros_like(b)
# 對卷積層輸入A_prev進行邊界填充;卷積運算時使用的是A_prev_pad
A_prev_pad = zero_pad(A_prev, pad)
dA_prev_pad = zero_pad(dA_prev, pad)
# 我們先計算dA_prev_pad,然后切片得到dA_prev
for i in range(m): # 遍歷樣本
# 選擇一個樣本
a_prev_pad = A_prev_pad[i]
da_prev_pad = dA_prev_pad[i]
for h in range(n_H):# 在輸出的垂直方向量循環
for w in range(n_W):# 在輸出的水平方向上循環
for c in range(n_C):# 在輸出的通道上循環
# 確定輸入數據的切片邊界
vert_start = h * strider
vert_end = vert_start + f
horiz_start = w * stride
horiz_end = horiz_start + f
a_slice = a_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :]
# 計算各梯度
da_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] += W[:,:,:,c] * dZ[i, h, w, c]
dW[:,:,:,c] += a_slice * dZ[i,h,w,c]
db[:,:,:,c] += dZ[i,h,w,c]
# 在填充后計算結果中切片得到填充之前的dA_prev梯度;
dA_prev[i, :, :, :] = da_prev_pad[pad:-pad, pad:-pad, :]
assert(dA_prev.shape == (m, n_H_prev, n_W_prev, n_C_prev))
return dA_prev, dW, db
我們來測試一下:
np.random.seed(1)
A_prev = np.random.randn(10,4,4,3)
W = np.random.randn(2,2,3,8)
b = np.random.randn(1,1,1,8)
hparameters = {"pad" : 2,
"stride": 2}
Z, cache_conv = conv_forward(A_prev, W, b, hparameters)
# Test conv_backward
dA, dW, db = conv_backward(Z, cache_conv)
print("dA_mean =", np.mean(dA))
print("dW_mean =", np.mean(dW))
print("db_mean =", np.mean(db))
輸出結果:
dA_mean = 1.45243777754
dW_mean = 1.72699145831
db_mean = 7.83923256462
5.2 池化層的反向傳播
接下來,我們選擇從最大池化開始實現池化層的反向傳播過程。盡管池化層沒有參數需要訓練,但是我們仍然需要計算梯度,因為我們需要將梯度通過池化層傳遞下去,計算下一層參數的梯度。
5.2.1 最大池化的反向傳播
在實現池化層的反向傳播之前,我們需要實現一個輔助函數create_mask_from_window(),用於實現:
這個函數依據輸入的X創建了一個掩碼,以保存輸入數據的最大值位置。掩碼中1表示對應位置為最大值(我們假設只有一個最大值)。
提示:
- np.max()用於計算輸入數組的最大值
- 矩陣X和實數x,那么,A = (X == x)將返回一個和X相同的矩陣,其中:
- A[i, j] = True if X[i, j] = x
- A[i, j] = False if X[i, j] != x
- 不考慮矩陣中存在多個最大值的情況
def create_mask_from_window(x):
"""
根據輸入X創建一個保存最大值位置的掩碼
參數:
x -- 輸入數據,維度為 (f, f)
返回值:
mask -- 形狀和x相同的保存其最大值位置的掩碼矩陣
"""
mask = (x == np.max(x))
return mask
我們來測試一下:
np.random.seed(1)
x = np.random.randn(2,3)
mask = create_mask_from_window(x)
print('x = ', x)
print("mask = ", mask)
輸出結果為:
x = [[ 1.62434536 -0.61175641 -0.52817175]
[-1.07296862 0.86540763 -2.3015387 ]]
mask = [[ True False False]
[False False False]]
5.2.2 平均池化的反向傳播
在最大池化中,每個輸入窗口的對輸出的影響僅僅來源於窗口的最大值;在平均池化中,窗口的每個元素對輸出結果有相同的影響。所以我們需要設計一個函數實現上述功能。
我們來看一個具體的例子:
def distribute_value(dz, shape):
"""
將梯度值均衡分布在shape的矩陣中
參數:
dz -- 標量,損失函數對某個參數的梯度
shape -- 輸出的維度(n_H, n_W)
Returns:
a -- 將dz均衡散布在(n_H, n_W)后的矩陣
"""
(n_H, n_W) = shape
# 每個元素的值
average = dz / (n_H * n_W)
# 輸出結果
a = np.ones(shape) * average
return a
我們來測試一下:
a = distribute_value(2, (2,2))
print('distributed value =', a)
輸出結果:
distributed value = [[ 0.5 0.5]
[ 0.5 0.5]]
5.2.3 代碼實現
將上述的輔助函數集合起來實現池化層的反向傳播過程:
def pool_backward(dA, cache, mode = "max"):
"""
實現池化層的反向傳播
參數:
dA -- 損失函數對池化層輸出數據A的梯度,維度和A相同
cache -- 池化層的緩存數據,包括輸入數據和超參數
mode -- 字符串,表明池化類型 ("max" or "average")
返回:
dA_prev -- 對池化層輸入數據A_prv的梯度,維度和A_prev相同
"""
(A_prev, hparameters) = cache
stride = hparameters['stride']
f = hparameters['f']
m, n_H_prev, n_W_prev, n_C_prev = A_prev.shape
m, n_H, n_W, n_C = dA.shape
# 對輸出結果進行初始化
dA_prev = np.zeros_like(A_prev)
for i in range(m):# 遍歷m個樣本
a_prev = A_prev[i]
for h in range(n_H):# 在垂直方向量遍歷
for w in range(n_W):#在水平方向上循環
for c in range(n_C):# 在通道上循環
# 找到輸入的分片的邊界
vert_start = h * stride
vert_end = vert_start + f
horiz_start = w * stride
horiz_end = horiz_start + f
# 根據池化方式選擇不同的計算過程
if mode == "max":
# 確定輸入數據的切片
a_prev_slice = a_prev[vert_start:vert_end, horiz_start:horiz_end, c]
# 創建掩碼
mask = create_mask_from_window(a_prev_slice)
# 計算dA_prev
dA_prev[i, vert_start: vert_end, horiz_start: horiz_end, c] += np.multiply(mask, dA[i,h,w,c])
elif mode == "average":
# 獲取da值, 一個實數
da = dA[i,h,w,c]
shape = (f, f)
# 反向傳播
dA_prev[i, vert_start: vert_end, horiz_start: horiz_end, c] += distribute_value(da, shape)
assert(dA_prev.shape == A_prev.shape)
return dA_prev
測試一下:
np.random.seed(1)
A_prev = np.random.randn(5, 5, 3, 2)
hparameters = {"stride" : 1, "f": 2}
A, cache = pool_forward(A_prev, hparameters)
dA = np.random.randn(5, 4, 2, 2)
dA_prev = pool_backward(dA, cache, mode = "max")
print("mode = max")
print('mean of dA = ', np.mean(dA))
print('dA_prev[1,1] = ', dA_prev[1,1])
print()
dA_prev = pool_backward(dA, cache, mode = "average")
print("mode = average")
print('mean of dA = ', np.mean(dA))
print('dA_prev[1,1] = ', dA_prev[1,1])
輸出結果為:
mode = max
mean of dA = 0.145713902729
dA_prev[1,1] = [[ 0. 0. ]
[ 5.05844394 -1.68282702]
[ 0. 0. ]]
mode = average
mean of dA = 0.145713902729
dA_prev[1,1] = [[ 0.08485462 0.2787552 ]
[ 1.26461098 -0.25749373]
[ 1.17975636 -0.53624893]]
至此,我們完成了卷積層和池化層的前向傳播和反向傳播過程,之后我們可以來構建卷積神經網絡。