卷積的基本概念就不說了,這裡只討論CNN中卷積層反向更新梯度時在多批量多卷積核多通道多通用公式

因為網上看到多卷積反向梯度更新的實現大多是單批量單通道的情況,自己動手實現一個MNIST數據集識別的CNN發現卡在多批量多通道多卷積核的梯度更新這裡了,乾脆自己來推導一個

首先從單批量,單通道單例子來入手:

卷積層的輸入X,是一個4x4的矩陣,W是2x2的卷積核,偏置b沒有畫上去,輸出是A,3x3,python代碼實現就是:

Z = conv(X, W) + b
A = g(Z) # g是激活函數,ReLU

關於conv函數的實現可以參考這一篇文章:卷積轉換為矩陣乘法

因為在正向計算是,W影響最終的誤差L的順序是:W——Z——A——L,所以,在求梯度時,根據鏈式法則:

frac{partial L}{partial W}=frac{partial L}{partial A} frac{partial A}{partial Z} frac{partial Z}{partial W}

其中A是卷積層的輸出,也是與之相連的下一層的輸入,所以 frac{partial L}{partial A} 這一項在上一層就計算出來了,是已知的,記做 dA

frac{partial A}{partial Z} 則可以用激活函數的導函數表示: g(Z)

剩下的: frac{partial Z}{partial W}=frac{partial}{partial W}(conv(X, W)+b)=frac{partial}{partial W}(conv(X, W))

看起來簡單,但是將輸入、輸出、卷積核都作為整體來分析的話就卡在最後一項上來,所以就進一步看具體的例子

根據卷積演算法的定義,可以知道 w_{0,0} 和所有的輸出值「有染」,但是隻和一部分輸入值發生「關係」,也就是下圖中紅框中的那一部分輸入有關係

所以,可知:

a_{0,0}=gleft(z_{0,0}
ight)=gleft(ldots+x_{0,0} w_{0,0}+cdots
ight)

a_{0,1}=gleft(z_{0,1}
ight)=gleft(ldots+x_{0,1} w_{0,0}+cdots
ight)

…………

a_{2,2}=gleft(z_{2,2}
ight)=gleft(ldots+x_{2,2} w_{0,0}+cdots
ight)

所以,求 frac{partial L}{partial w_{0,0}} 需要用到全倒數:

frac{partial L}{partial w_{0,0}}=sum_{m=0}^{2} sum_{n=0}^{2} frac{partial L}{partial a_{m, n}} frac{partial a_{m, n}}{partial z_{m, n}} frac{partial z_{m, n}}{partial w_{0,0}}=sum_{m=0}^{2} sum_{n=0}^{2} d a_{m, n} g^{prime}left(z_{m, n}
ight) x_{m, n}

從公式中可知,涉及到了矩陣A、Z的所有值和輸入矩陣X的一部分,並且大小剛好和前兩項一樣,將其當成X的子矩陣,就變成更一般的公式:

frac{partial L}{partial w_{i, i}}=sum(d A cdot g^{prime}(Z) cdot X_{i : i+m, j : j+n})

注意,這不是矩陣乘法,而是矩陣的每個元素對於相乘,用python實現如下:

m, n = dA.shape # m,n分別是dA的輸出的邊長,一般是相同的
for i in range(filtersize): # 顯然,filtersize是卷積核的邊長
for j in range(filtersize):
dW[i,j] = np.sum(dA * f(Z) * X[i:i+m,j:j+n]) # f是激活函數的導函數

而關於偏置的梯度,只用改變上面的一項: frac{partial z_{m, n}}{partial w_{0,0}} ,換成 frac{partial z_{m, n}}{partial b} ,而且值為1,所以:

frac{partial L}{partial b}=sum_{m=0}^{2} sum_{n=0}^{2} frac{partial L}{partial a_{m, n}} frac{partial a_{m, n}}{partial z_{m, n}}

將上面的實現改進一下就可以了:

dZ = dA * f(Z)
db = np.sum(dZ)
for i in range(filtersize): # 顯然,filtersize是卷積核的邊長
for j in range(filtersize):
dW[i,j] = np.sum(dZ * X[i:i+m,j:j+n]) # f是激活函數的導函數

對於卷積核的梯度更新實現,可以進一步轉換成矩陣乘法來實現,同時也是為後面做鋪墊:

dZ = dA * f(Z)
db = np.sum(dZ)
dZ = dZ.ravel()
for i in range(filtersize): # 顯然,filtersize是卷積核的邊長
for j in range(filtersize):
Xsub = X[i:i+m,j:j+n].ravel()
dW[i,j] = np.dot(dZ, Xsub.T)

以上,就是單核,單通道,單批量單梯度計算過程,下面接著看多通道的情況

因為和以上相比只是多了一個通道,其他條件不變,相應的輸入也多了一個通道,如下圖:

前向計算:

Z = conv(X, W) + b
A = g(Z)

實現還是不變,因為conv是直接參照4維實現多(N,C,H,W),所以可以直接用,然後是反向,具體到實例就有些變化了:

a_{0,0}=gleft(z_{0,0}
ight)=gleft(ldots+x_{0,0,0} w_{0,0,0}+x_{1,0,0} w_{1,0,0}+cdots
ight)

a_{0,1}=gleft(z_{0,1}
ight)=gleft(ldots+x_{0,0,1} w_{0,0,0}+x_{1,0,1} w_{1,0,0}+cdots
ight)

a_{2,2}=gleft(z_{2,2}
ight)=gleft(ldots+x_{0,2,2} w_{0,0,0}+x_{1,2,2} w_{1,0,0}+cdots
ight)

然後是求梯度:

frac{partial L}{partial w_{0,0,0}}=sum_{m=0}^{2} sum_{n=0}^{2} frac{partial L}{partial a_{m, n}} frac{partial a_{m, n}}{partial z_{m, n}} frac{partial z_{m, n}}{partial w_{0,0,0}}=sum_{m=0}^{2} sum_{n=0}^{2} d a_{m, n} g^{prime}left(z_{m, n}
ight) x_{0, m, n}

可以看到,基本上沒有變化,一毛一樣,轉換成更一般的形式:

frac{partial L}{partial w_{c, i, i}}=d A cdot g^{prime}(Z) cdot X_{c, i : i+m, j : j+n}

實現起來就更簡單了:

dZ = dA * f(Z)
db = np.sum(dZ)
dZ = dZ.ravel()
for i in range(filtersize): # 顯然,filtersize是卷積核的邊長
for j in range(filtersize):
Xsub = X[:,i:i+m,j:j+n].reshape(FC, -1) # FC是通道數
dW[:,i,j] = np.dot(dZ, Xsub.T)

除了Xsub由ravel變成reshape得來,其他都沒有變化。

接下來,進一步升級多核,而且輸出值也會變成多通道:

如圖所示,沒多一個核對應的輸出就多一個通道,輸出的各個通道都只與相應的卷積核有關係,而與其他核沒有半毛錢關係,反之各個卷積核也是如此,所以,如果將一個卷積核與一個輸出的通道關聯成一組,每組都可以單獨處理,代碼基本可以參照上面的,只不過要做一些修改,首先,這裡要申明一下各個值的維度:

FN, m, n = Z.shape # 分別是核數、高、寬,dA也是一樣的
FN = db.shape # db就是一個一維向量,長度和核數相同
FN, FC, filtersize, filtersize = dW.shape # 核數,通道數,邊長,邊長

首先是db的變化:

dZ = dA * f(Z)
db = np.sum(dZ, axis=(1,2))

很好理解,dZ變成3維了,所以將其最後2個維度求和就得到了db的向量。

然後輸入的維度沒有變,Xsub也不用變,需要變化的是dZ轉換成一個矩陣

Xsub = X[:, i:i + m, j:j + n].reshape(FC, -1)
dZ = dZ.reshape(FN, -1)
dW[:,:,i,j] = np.dot(dZ, Xsub.T)

Xsub是一個(FC, mxn)的矩陣,dZ是一個(FN, mxn)的矩陣,兩者相乘正好對上dW[:, :, i, j]

dZ = dA * f(Z)
db = np.sum(dZ, axis=(1,2))
dZ = temp.reshape(FN, -1)
for i in range(filtersize):
for j in range(filtersize):
Xsub = X[:, i:i + m, j:j + n].reshape(FC, -1)
dW[:,:,i,j] = np.dot(dZ, Xsub.T)

至此,多核,多通道的卷積層求梯度也就解決了,最後,就只剩一個問題了

嗯,就是多批量的數據輸入時的梯度更新,如果是用SGD來更新梯度的話,以上已經是夠用了

先來瞭解當批量輸入是各個變數的維度:

FN, FC, filtersize, filtersize = dW.shape
N, FN, m, n = dZ.shape
N, FC, m, n = Xsub.shape

先來看db的更新:

dZ = dA * f(Z)
db = np.sum(dZ, axis=(0,2,3)) / N

因為是批量輸入,所以梯度也是所有批數據的總和,需要除以相關的批量,得到一個平均梯度,或者可以百度批量梯度下降(MGD),然後看看dW的更新:

Xsub = X[:, i:i + m, j:j + n].reshape(N, FC, m*n)
dZ = dZ.reshape(N, FN, m*n)
temp = np.empty((N, FN, FC))
for n in range(N):
temp[n] = np.dot(dZ, Xsub.T)
dW[:, :, i, j] = np.sum(temp, axis=0) / N

以上代碼雖然實現了需要的功能,但是如果N,也就是批量特別大的話,用python進行遍歷就會很悲劇,而且也不利於並行,所以改了一下代碼,如下:

dZ = dA * f(Z)
db = np.sum(dZ, axis=(0,2,3)) / N
dZ = dZ.reshape(N, FN, -1).transpose(1, 0, 2).reshape(FN, -1)
for i in range(filtersize):
for j in range(filtersize):
Xsub = X[:, :, i:i + m, j:j + n].reshape(N, FC, -1).transpose(1, 0, 2).reshape(FC, -1)
dW[:,:,i,j] = np.dot(dZ, Xsub.T) # 這裡先不除N都可以,放在最後除一起除

證明如下,先看數學推導,設有三個矩陣 A_{1},B_{1},C_{1} ,並且 C_{1}=A_{1}B_{1}

A_{1}=left[ egin{array}{ccc}{a_{11}^{1}} & {cdots} & {a_{1 k}^{1}} \ {vdots} & {ddots} & {vdots} \ {a_{i 1}^{1}} & {cdots} & {a_{i k}^{1}}end{array}
ight]

B_{1}=left[ egin{array}{ccc}{b_{11}^{1}} & {cdots} & {b_{1 j}^{1}} \ {vdots} & {ddots} & {vdots} \ {b_{k 1}^{1}} & {cdots} & {b_{k j}^{1}}end{array}
ight]

C_{1}=A_{1} B_{1}=left[ egin{array}{ccc}{c_{11}^{1}} & {cdots} & {c_{1 j}^{1}} \ {vdots} & {ddots} & {vdots} \ {c_{i 1}^{1}} & {cdots} & {c_{i j}^{1}}end{array}
ight]

第一個遍歷所有批量的實現用這三個矩陣表示就相當於:

for n in range(N):
C[n] = A[n].dot(B[n])
C = C.sum(axis=0)

公式表示如下:

C=C_{1}+C_{2}+cdots+C_{N}=A_{1} B_{1}+A_{2} B_{2}+cdots+A_{N} B_{N}

C=left[ egin{array}{ccc}{sum_{n} c_{11}^{n}} & {cdots} & {sum_{n} c_{1 j}^{n}} \ {vdots} & {ddots} & {vdots} \ {sum_{n} c_{i 1}^{n}} & {cdots} & {sum_{n} c_{i j}^{n}}end{array}
ight]

這就是第一種方法的結果,接下來看第二種方法,先構造兩個矩陣:

A=left[ egin{array}{llll}{A_{1}} & {A_{2}} & {cdots} & {A_{n}}end{array}
ight]=left[ egin{array}{ccccccccc}{a_{11}^{1}} & {cdots} & {a_{1 k}^{1}} & {a_{11}^{2}} & {cdots} & {a_{1 k}^{2}} & {cdots} & {a_{11}^{n}} & {cdots} & {a_{1 k}^{n}} \ {vdots} & {ddots} & {vdots} & {vdots} & {ddots} & {vdots} & {cdots} & {vdots} & {ddots} & {vdots} \ {a_{i 1}^{1}} & {cdots} & {a_{i k}^{2}} & {a_{i 1}^{2}} & {cdots} & {a_{i k}^{2}} & {cdots} & {a_{i 1}^{n}} & {cdots} & {a_{i k}^{n}}end{array}
ight]

B=left[ egin{array}{c}{B_{1}} \ {B_{2}} \ {vdots} \ {B_{n}}end{array}
ight]=left[ egin{array}{ccc}{b_{11}^{1}} & {cdots} & {b_{1 j}^{1}} \ {vdots} & {ddots} & {vdots} \ {b_{k 1}^{1}} & {cdots} & {b_{k j}^{1}} \ {b_{11}^{2}} & {cdots} & {b_{k j}^{2}} \ {vdots} & {ddots} & {vdots} \ {b_{k 1}^{2}} & {cdots} & {b_{k j}^{2}} \ {b_{11}^{n}} & {cdots} & {b_{k j}^{n}} \ {vdots} & {ddots} & {vdots} \ {b_{k 1}^{n}} & {cdots} & {b_{k j}^{n}}end{array}
ight]

相信很好理解,就是把A1-An以及B1至Bn各自按橫豎方向並成一個大矩陣,也就是第二種實現方法裏將dZ以及Xsub一頓操作變形的結果,只要 C=AB ,就說明兩種方法結果是一樣的:

ecause C_{i j}=sum_{n} c_{i j}^{n}=sum_{n} sum_{k} a_{i k}^{n} b_{k j}^{n}

A_{i *} B_{* j} 表示矩陣A的第i行和矩陣B的第j列相乘:

A_{i *} B_{* j}=sum_{k} a_{i k}^{1} b_{k j}^{1}+sum_{k} a_{i k}^{2} b_{k j}^{2}+cdots+sum_{k} a_{i k}^{n} b_{k j}^{n}

=c_{i j}^{1}+c_{i j}^{2}+cdots+c_{i j}^{n}

=sum_{n} c_{i j}^{n}=C_{ij}

	herefore AB=C

上面的推導應該很明白了,不過還是放幾個圖輔助說明一下,先看看dZ和Xsub的內存結構:

然後是變形:

dZ = dZ.transpose(1, 0, 2).reshape(FN, -1)

經過以上處理,就變成了如下樣子:

Xsub也同樣處理,最後結果就是我們想要的了:

以上,就是多批量多核多通道的處理梯度更新過程了

推薦閱讀:

相關文章