推薦在我的博客中給我留言抡爹,這樣我會(huì)隨時(shí)收到你的評論,并作出回復(fù)。
在上一篇神經(jīng)網(wǎng)絡(luò)的Python實(shí)現(xiàn)(二)全連接網(wǎng)絡(luò)中肾档,已經(jīng)介紹了神經(jīng)網(wǎng)絡(luò)的部分激活函數(shù),損失函數(shù)和全連接網(wǎng)絡(luò)的前饋和反向傳播公式及Numpy實(shí)現(xiàn)辫继。這篇博文將要詳細(xì)介紹卷積神經(jīng)網(wǎng)絡(luò)的概念怒见,并且進(jìn)行前饋和反向傳播的公式推導(dǎo)及Numpy實(shí)現(xiàn)。
卷積神經(jīng)網(wǎng)絡(luò)
卷積神經(jīng)網(wǎng)絡(luò)(Convolutional Neural Network)非常擅于處理圖像任務(wù)姑宽,它的靈感來自于視覺神經(jīng)中的感受野
這一概念遣耍,卷積神經(jīng)網(wǎng)絡(luò)的卷積核(Convolution Kernel) 好似感受野一樣去掃描數(shù)據(jù)。一個(gè)卷積神經(jīng)網(wǎng)絡(luò)基本包括卷積層炮车、池化層和輸出層舵变。
接下來介紹什么是卷積核酣溃、卷積神經(jīng)網(wǎng)絡(luò)中的卷積是怎么運(yùn)算的。
卷積和卷積核
卷積神經(jīng)網(wǎng)絡(luò)中的卷積操作與數(shù)學(xué)中的類似纪隙。就是輸入數(shù)據(jù)中不同數(shù)據(jù)窗口的數(shù)據(jù)和卷積核(一個(gè)權(quán)值矩陣)作內(nèi)積的操作赊豌。其中卷積核是卷積神經(jīng)網(wǎng)絡(luò)中卷積層的最重要的部分。卷積核相當(dāng)于信息處理中的濾波器绵咱,可以提取輸入數(shù)據(jù)的當(dāng)前特征碘饼。卷積核的實(shí)質(zhì)是一個(gè)權(quán)值矩陣,在下圖中的卷積核便是一個(gè)權(quán)值如下的矩陣(圖中黃色色塊中的紅色數(shù)字)
如果并不理解卷積悲伶,那么我們來看圖中輸出的第一行第一列的4是怎么得到的艾恼。
原輸入數(shù)據(jù)大小為55,我們要使用33的卷積核來進(jìn)行卷積麸锉,我們使用表示卷積操作钠绍。那么圖中第一個(gè)4的運(yùn)算過程就可以表達(dá)為:
剩下位置的輸出就是卷積核在輸入矩陣上從左到右從上到下移動(dòng)一格做如上卷積操作過程的結(jié)果。
步長(strides)和填充(padding)
上面例子說到的一格表示的就是步長(strides)花沉,步長分為橫向步長和縱向步長柳爽,步長是多少就表示一次卷積操作之后卷積核移動(dòng)的距離。知道步長的概念了主穗,我們就可以去計(jì)算一下根據(jù)輸入大小泻拦,卷積核大小,我們得到的輸出的大小忽媒。假設(shè)用表示邊長,那么:
根據(jù)公式當(dāng)步長為1或是輸入大小能夠被步長整除時(shí)很好處理争拐,無法整除時(shí)也就是卷積核移動(dòng)到最后,輸入數(shù)據(jù)的剩下的部分不足卷積核大小晦雨,這時(shí)我們會(huì)想到要么將輸入變大點(diǎn)讓它能夠整除要么是干脆邊界直接丟棄讓它能夠整除架曹。這兩種處理辦法對應(yīng)于填充(padding) 的兩種方式,'SAME'和'VALID'闹瞧。
VALID
其中為向上取整,是寬方向绑雄,是長方向, 分別代表輸入、輸出奥邮、卷積核和步長万牺。
超過部分就舍棄不要了。
真正輸入大小
SAME
SAME就是在輸入周圍補(bǔ)0洽腺,我們先計(jì)算補(bǔ)0后的輸出大薪潘凇:
接下來便根據(jù)應(yīng)得到輸出的大小去padding。
其中 是向下取整蘸朋。
這樣0就幾乎對稱地分布在輸入四周核无。
多通道的卷積
一般卷積神經(jīng)網(wǎng)絡(luò)處理的都是3通道或是多通道的圖像數(shù)據(jù),那么對于多通道如何卷積呢藕坯?對于多通道团南,卷積公式并不變噪沙,只是要求卷積核通道與輸入通道數(shù)一致,不同通道分別做內(nèi)積吐根,然后不同通道得到的值相加起來作為最后的輸出正歼。如圖。
對于計(jì)算拷橘,我們使用的輸入和的卷積核舉個(gè)例子:
可以看到朋腋,不論輸入通道數(shù)是多少最后的輸出仍是一個(gè)矩陣。在卷積層如果有多個(gè)卷積核膜楷,每個(gè)卷積核會(huì)提取一種特征,輸出一個(gè)二維矩陣贞奋。最終的結(jié)果就是把這些卷積核的輸出看作不同通道赌厅。如下圖。
下面以圖像處理為例轿塔,來看一下卷積神經(jīng)網(wǎng)絡(luò)的前饋和反向傳播特愿。
前向傳播
卷積層的前向傳播方式與全連接層類似,我們回顧一下全連接層的前向傳播:
卷積層只不過把全連接層的矩陣乘法運(yùn)算換成了卷積運(yùn)算勾缭。詳細(xì)的步驟如下
知道前一層的輸出之后:
- 定義好卷積核數(shù)目揍障,卷積核大小,步長和填充方式俩由。根據(jù)輸入大小毒嫡,計(jì)算輸出大小并進(jìn)行相應(yīng)的padding,得到了卷積層的輸入 幻梯。
- 初始化所有卷積和的權(quán)重 和偏置
- 根據(jù)前向傳播的公式(M個(gè)通道):
即
計(jì)算出卷積層輸出兜畸,其中是卷積運(yùn)算、是激活函數(shù)碘梢。
反向傳播
現(xiàn)在已知卷積層的 咬摇,我們通過反向傳播算法來計(jì)算上一層的 。
我們也先回顧一下反向傳播公式煞躬,根據(jù)鏈?zhǔn)椒▌t:
要計(jì)算的值肛鹏,必須知道的值,所以根據(jù)前向傳播公式:
這里我們將 和 拿出來看:
現(xiàn)在就差卷積運(yùn)算的偏導(dǎo)該如何求恩沛,我們先把正確公式寫出來在扰,之后再解釋:
這里的 表示將卷積核旋轉(zhuǎn)180°,即卷積核左右翻轉(zhuǎn)之后再上下翻轉(zhuǎn)复唤〗√铮可以拿張正反內(nèi)容不一樣的紙轉(zhuǎn)一轉(zhuǎn)。然后我們解釋為什么卷積的求導(dǎo)就是將卷積核旋轉(zhuǎn)180°再做卷積的結(jié)果佛纫。
我們拿 大小矩陣作為例子妓局,卷積核大小為 总放,步長為1(步長不是1時(shí)后面會(huì)提到):
上面是前向的卷積運(yùn)算,我們把它展開來:
這樣就變成了簡單的運(yùn)算好爬,根據(jù)反向傳播公式:
我們就要對每個(gè) 求其梯度:
比如 只和 有關(guān)局雄,所以:
復(fù)雜點(diǎn)的對于為了明顯標(biāo)紅的 ,它與 都有關(guān)存炮,所以:
類似地我們把所有的 都求出來:
如果你嘗試過padding過的卷積運(yùn)算炬搭,你會(huì)發(fā)現(xiàn)上面的式子就是下面列出的卷積(步長為1):
這里最好動(dòng)手寫一下就可以發(fā)現(xiàn)這種運(yùn)算關(guān)系。(這里的周圍的0填充寬度是卷積核邊長-1)
以上是步長為1時(shí)的求解過程穆桂,當(dāng)步長大于1時(shí)宫盔,我們就需要在 矩陣的值之間填充0來實(shí)現(xiàn)步長。
先說結(jié)論享完,每兩個(gè) 之間需要填充步長-1個(gè)0(對應(yīng)方向上的)灼芭。也舉個(gè)例子來看看是不是這樣,步長為2般又。
展開來:
計(jì)算梯度:
即:
注:無論前向步長為多少彼绷,旋轉(zhuǎn)后的卷積步長一直是1。
其余計(jì)算過程類比全連接層是一樣的茴迁。至此寄悯,卷積層的反向傳播就結(jié)束了。
下面我們使用Numpy來實(shí)現(xiàn)卷積層的前向和反向傳播堕义。
CODE
代碼是在上一篇全連接網(wǎng)絡(luò)基礎(chǔ)上增加的猜旬,繼承自Layer
類,使得不同類型的層可以疊加成網(wǎng)絡(luò)倦卖。
卷積層
首先我們定義一個(gè)卷積核類昔馋,用來實(shí)現(xiàn)每個(gè)卷積核的卷積計(jì)算和前向傳播反向傳播。
class ConvKernel(Layer):
"""
這里不需要繼承自Layer糖耸,但是把激活函數(shù)求導(dǎo)過程放在了這里秘遏,沒改所以還是繼承了。
"""
def __init__(self, kernel_size, input_shape, strides):
"""
:param kernel_size: 卷積核大小
:param input_shape: 輸入大小
:param strides: 步長大小
"""
super().__init__()
self.__kh = kernel_size[0]
self.__kw = kernel_size[1]
self.__input_shape = input_shape
self.__channel = input_shape[2]
self.__strides = strides
# self.__padding = padding
self.__w = np.random.randn(kernel_size[0], kernel_size[1],
input_shape[2]) # np.array([[1,0,1],[0,1,0],[1,0,1]])
self.__output_shape = (int((input_shape[0] - kernel_size[0]) / strides[0]) + 1,
int((input_shape[1] - kernel_size[1]) / strides[1]) + 1)
self.__input = None
self.__output = None
self.__b = np.random.randn(self.__output_shape[0], self.__output_shape[1])
def __flip_w(self):
"""
:return: w after flip 180
"""
return np.fliplr(np.flipud(self.__w))
def __updata_params(self, w_delta, b_delta, lr):
self.__w -= w_delta * lr
self.__b -= b_delta * lr
def __conv(self, _input, weights, strides, _axis=None):
"""
卷積運(yùn)算
:param _input: 輸入
:param weights: 權(quán)重
:param strides: 步長
:param _axis: 維度
:return:
"""
if _axis is None: # 矩陣情況
result = np.zeros((int((_input.shape[0] - weights.shape[0]) / strides[0]) + 1,
int((_input.shape[1] - weights.shape[1]) / strides[1]) + 1))
for h in range(result.shape[0]):
for w in range(result.shape[1]):
result[h, w] = np.sum(_input[h * strides[0]:h * strides[0] + weights.shape[0],
w * strides[1]:w * strides[1] + weights.shape[1]] * weights)
else:
result = np.zeros((int((_input.shape[0] - weights.shape[0]) / strides[0]) + 1,
int((_input.shape[1] - weights.shape[1]) / strides[1]) + 1,
self.__input_shape[2]))
for h in range(result.shape[0]):
for w in range(result.shape[1]):
result[h, w, :] = np.sum(_input[h * strides[0]:h * strides[0] + weights.shape[0],
w * strides[1]:w * strides[1] + weights.shape[1]] * weights,
axis=_axis)
return result
def forward_pass(self, X):
self.__input = X
self.__output = self.__conv(X, self.__w, self.__strides) + self.__b
return self.__output
def back_pass(self, error, lr, activation_name='none'):
o_delta = np.zeros((self.__output_shape[0], self.__output_shape[1], self.__channel))
# 將delta擴(kuò)展至通道數(shù)
for i in range(self.__channel):
o_delta[:, :, i] = error
# 根據(jù)輸入嘉竟、步長邦危、卷積核大小計(jì)算步長
X = np.zeros(
shape=(self.__input_shape[0] + self.__kh - 1, self.__input_shape[1] + self.__kw - 1, self.__channel))
o_delta_ex = np.zeros(
(self.__output_shape[0], self.__output_shape[1],
self.__channel))
# 根據(jù)步長填充0
for i in range(o_delta.shape[0]):
for j in range(o_delta.shape[1]):
X[self.__kh - 1 + i * self.__strides[0],
self.__kw - 1 + j * self.__strides[1], :] = o_delta[i, j, :]
# print(o_delta_ex.shape,o_delta.shape)
o_delta_ex[i, j, :] = o_delta[i, j, :]
flip_conv_w = self.__conv(X, self.__flip_w(), (1, 1), _axis=(0, 1))
delta = flip_conv_w * np.reshape(
self._activation_prime(activation_name, self.__input),
flip_conv_w.shape)
w_delta = np.zeros(self.__w.shape)
for h in range(w_delta.shape[0]):
for w in range(w_delta.shape[1]):
if self.__channel == 1:
w_delta[h, w, :] = np.sum(self.__input[h:h + o_delta_ex.shape[0],
w:w + o_delta_ex.shape[1]] * o_delta_ex)
else:
w_delta[h, w, :] = np.sum(self.__input[h:h + o_delta_ex.shape[0],
w:w + o_delta_ex.shape[1]] * o_delta_ex, axis=(0, 1))
self.__updata_params(w_delta, error, lr)
return delta
之后再定義卷積層
class ConvLayer(Layer):
def __init__(self, filters, kernel_size, input_shape, strides, padding, activation, name="conv"):
"""
:param filters: 卷積核個(gè)數(shù)
:param kernel_size: 卷積核大小
:param input_shape: 輸入shape
:param strides: 步長
:param padding: 填充方式
:param activation: 激活函數(shù)名
:param name: 層名稱
"""
super().__init__()
self.__filters = filters
self.__kernel_size = kernel_size
self.__strides = strides
self.__padding = padding
self.activation_name = activation
self.__input_shape = input_shape # eg 64*64*3
self.__input_padding_shape = input_shape
self.__input = np.zeros(self.__input_shape)
self.name = name
self.flag = False
def _padding_X(self, X):
"""
對輸入進(jìn)行padding
:param X: 輸入
:return: 輸入padding后的值
"""
if self.__padding == 'SAME':
o_w = int(np.ceil(X.shape[0] / self.__strides[0]))
o_h = int(np.ceil(X.shape[1] / self.__strides[1]))
self.__output_size = (o_w, o_h, self.__filters)
p_w = np.max((o_w - 1) * self.__strides[0] + self.__kernel_size[0] - X.shape[0], 0)
p_h = np.max((o_h - 1) * self.__strides[1] + self.__kernel_size[1] - X.shape[1], 0)
self.p_l = int(np.floor(p_w / 2))
self.p_t = int(np.floor(p_h / 2))
res = np.zeros((X.shape[0] + p_w, X.shape[1] + p_h, X.shape[2]))
res[self.p_t:self.p_t + X.shape[0], self.p_l:self.p_l + X.shape[1], :] = X
return res
elif self.__padding == 'VALID':
o_w = int(np.ceil((X.shape[0] - self.__kernel_size[0] + 1) / self.__strides[0]))
o_h = int(np.ceil((X.shape[1] - self.__kernel_size[1] + 1) / self.__strides[1]))
self.__output_size = (o_w, o_h, self.__filters)
return X[:self.__strides[0] * (o_w - 1) + self.__kernel_size[0],
:self.__strides[1] * (o_h - 1) + self.__kernel_size[1], :]
else:
raise ValueError("padding name is wrong")
def forward_propagation(self, _input):
"""
前向傳播,在前向傳播過程中得到輸入值舍扰,并計(jì)算輸出shape
:param _input: 輸入值
:return: 輸出值
"""
self.__input = self._padding_X(_input)
self.__input_padding_shape = self.__input.shape
self.__output = np.zeros(self.__output_size)
if not self.flag: # 初始化
self.__kernels = [ConvKernel(self.__kernel_size, self.__input_padding_shape, self.__strides) for _ in
range(self.__filters)] # 由于隨機(jī)函數(shù)倦蚪,所以不能使用[]*n來創(chuàng)建多個(gè)(數(shù)值相同)。
self.flag = True
for i, kernel in enumerate(self.__kernels):
self.__output[:, :, i] = kernel.forward_pass(self.__input)
return self._activation(self.activation_name, self.__output)
def back_propagation(self, error, lr):
"""
反向傳播過程边苹,對于誤差也需要根據(jù)padding進(jìn)行截取或補(bǔ)0
:param error: 誤差
:param lr: 學(xué)習(xí)率
:return: 上一層誤差(所有卷積核的誤差求平均)
"""
delta = np.zeros(self.__input_shape)
for i in range(len(self.__kernels)):
index = len(self.__kernels) - i - 1
tmp = self.__kernels[index].back_pass(error[:, :, index], lr, self.activation_name)
if self.__padding == 'VALID':
bd = np.ones(self.__input_shape)
bd[:self.__input_padding_shape[0], :self.__input_padding_shape[1]] = tmp
elif self.__padding == 'SAME':
bd = tmp[self.p_t:self.p_t + self.__input_shape[0], self.p_l:self.p_l + self.__input_shape[1]]
else:
raise ValueError("padding name is wrong")
delta += bd
return delta / len(self.__kernels)
以上是卷積層的前向和反向傳播實(shí)現(xiàn)陵且。需要自己定義好輸入維度,不正確會(huì)報(bào)錯(cuò)。卷積神經(jīng)網(wǎng)絡(luò)大多用作圖像的分類任務(wù)慕购,所以我們還要實(shí)現(xiàn)分類任務(wù)需要的softmax激活函數(shù)和交叉熵(cross entropy) 損失函數(shù)聊疲。
softmax激活函數(shù)和交叉熵(cross entropy)損失函數(shù)詳細(xì)的推導(dǎo)將會(huì)放在下一篇中講解,這里先給出代碼實(shí)現(xiàn)沪悲。
softmax 和 cross entropy
softmax
def _activation(self, name, x):
#···
#···其他激活函數(shù)(詳細(xì)見上篇)
#···
elif name == 'softmax':
x = x - np.max(x) # 防止過大
exp_x = np.exp(x)
return exp_x / np.sum(exp_x)
def _activation_prime(self, name, x):
elif name == 'softmax':
x = np.squeeze(x)
#print(x)
length = len(x)
res = np.zeros((length,length))
# print("length", length)
for i in range(length):
for j in range(length):
res[i,j] = self.__softmax(i, j, x)
return res
def __softmax(self, i, j, a):
if i == j:
return a[i] * (1 - a[i])
else:
return -a[i] * a[j]
cross entropy
def __cross_entropy(self, output, y, loss):
output[output == 0] = 1e-12
if loss:
return -y * np.log(output)
else:
return - y / output
經(jīng)過卷積層得到的輸出一般是多通道的获洲,我們想要接全連接層去進(jìn)行分類還需要將多通道數(shù)據(jù)展成一維向量,就需要Flatten層殿如。只是數(shù)據(jù)位置的變換贡珊,看代碼就好。
import numpy as np
from Layer import Layer
class FlattenLayer(Layer):
def __init__(self):
super().__init__()
self.__input_shape = None
self.activation_name = 'none'
def forward_propagation(self, _input):
self.__input_shape = _input.shape
return _input.flatten()
def back_propagation(self, error, lr=1):
return np.resize(error, self.__input_shape)
結(jié)果
我使用了200張MNIST手寫數(shù)據(jù)涉馁,以0.03的學(xué)習(xí)率訓(xùn)練了100輪之后门岔,對測試集的40張進(jìn)行了預(yù)測,(隨便瞎寫的模型)結(jié)果如下:
epochs 1 / 100 loss : 1.6076663151543635
epochs 2 / 100 loss : 1.51308051414868
epochs 3 / 100 loss : 1.4435877198762985
epochs 4 / 100 loss : 1.4170579907154772
epochs 5 / 100 loss : 1.2782959961456577
epochs 6 / 100 loss : 0.9999002367380303
···
TODO
卷積層常常需要搭配池化層進(jìn)行數(shù)據(jù)的降維烤送,所以下一篇會(huì)繼續(xù)實(shí)現(xiàn)池化層和講解Softmax與cross entropy固歪。
參考內(nèi)容
感謝以下博主的文章,感謝YJango的過程可視化圖片胯努。
[1] 能否對卷積神經(jīng)網(wǎng)絡(luò)工作原理做一個(gè)直觀的解釋?-知乎
[2] 【TensorFlow】一文弄懂CNN中的padding參數(shù)
[3] 卷積神經(jīng)網(wǎng)絡(luò)(CNN)反向傳播算法