在上一篇 神經(jīng)網(wǎng)絡(luò)的Python實現(xiàn)(一)了解神經(jīng)網(wǎng)絡(luò) 中,我們簡單介紹了感知機模型和多層網(wǎng)絡(luò)的基礎(chǔ)結(jié)構(gòu)。在這篇博文中,我們將使用python-numpy庫搭建多層神經(jīng)網(wǎng)絡(luò)模型、介紹和實現(xiàn)BP算法。理論部分有部分參考。
全連接網(wǎng)絡(luò)
首先,簡單介紹一下全連接網(wǎng)絡(luò)(Fully-Connected Network),即在多層神經(jīng)網(wǎng)絡(luò)中,第 層的每個神經(jīng)元都分別與第
層的神經(jīng)元相互連接。如下圖便是一個簡單的全連接網(wǎng)絡(luò):

我們使用圓圈來表示神經(jīng)網(wǎng)絡(luò)的輸入,標(biāo)上 的圓圈被稱為偏置節(jié)點,也就是截距項。神經(jīng)網(wǎng)絡(luò)最左邊的一層叫做輸入層,最右的一層叫做輸出層(上圖中,輸出層只有一個節(jié)點)。中間所有節(jié)點組成的一層叫做隱藏層,因為我們不能在訓(xùn)練樣本集中觀測到它們的值。同時可以看到,以上神經(jīng)網(wǎng)絡(luò)的例子中有3個輸入單元(偏置單元不計在內(nèi)),3個隱藏單元及一個輸出單元。
我們用 來表示網(wǎng)絡(luò)的層數(shù),上圖例子中
,我們將第
層記為
,于是
是輸入層,輸出層是
。本例神經(jīng)網(wǎng)絡(luò)有參數(shù)
,其中
是第
層第
單元與第
層第
單元之間的聯(lián)接參數(shù)(其實就是連接線上的權(quán)重,注意標(biāo)號順序),
是第
層第
單元的偏置項。因此在本例中,
,
。注意,沒有其他單元連向偏置單元(即偏置單元沒有輸入),因為它們總是輸出
。同時,我們用
表示第
層的節(jié)點數(shù)(偏置單元不計在內(nèi))。
接下來詳細(xì)介紹神經(jīng)網(wǎng)絡(luò)的前向和反向的計算過程。
前向傳播
我們用 表示第
層第
單元的激活值(輸出值)。當(dāng)
時,
,也就是第
個輸入值(輸入值的第
個特征)。對于給定參數(shù)集合
,我們的神經(jīng)網(wǎng)絡(luò)就可以按照函數(shù)
來計算輸出結(jié)果。本例神經(jīng)網(wǎng)絡(luò)的計算步驟如下:
我們用 表示第
層第
單元輸入加權(quán)和(包括偏置單元),比如,
則
這樣我們就可以得到一種更簡潔的表示法。這里我們將激活函數(shù) 擴(kuò)展為用向量(分量的形式)來表示,即
,那么,上面的等式可以更簡潔地表示為:
我們將上面的計算步驟叫作前向傳播?;叵胍幌?,之前我們用 表示輸入層的激活值,那么給定第
層的激活值
后,第
層的激活值
就可以按照下面步驟計算得到:
將參數(shù)矩陣化,使用矩陣-向量運算方式,我們就可以利用線性代數(shù)的優(yōu)勢對神經(jīng)網(wǎng)絡(luò)進(jìn)行快速求解。
# 在python 3 numpy 中,矩陣相乘可以使用 a @ b
z = activation(a @ w + b)
激活函數(shù)
在上面例子中 便是激活函數(shù),是神經(jīng)網(wǎng)絡(luò)中十分重要的一環(huán)。若沒有激活函數(shù),那么神經(jīng)網(wǎng)絡(luò)的輸出便始終只是各個輸入的線性組合。“深度”起不到作用。
所以激活函數(shù)的作用便是加入某種非線性的映射。早期經(jīng)常使用的是Sigmoid函數(shù),近幾年多使用ReLU函數(shù)及其變體。下面介紹一下常見的激活函數(shù)及其導(dǎo)數(shù)。
1. sigmoid

數(shù)學(xué)形式:
Sigmoid函數(shù)會將輸入映射到(0,1)的范圍,較大的值會被映射為1,較小的值會被映射為0。直觀上符合神經(jīng)元活躍與抑制狀態(tài)的區(qū)分。
缺點:
- 如圖,輸入值的絕對值在4以上的情況下就基本趨于飽和了,達(dá)到1或0。在反向傳播時,會造成由于梯度過小而產(chǎn)生權(quán)重更新緩慢甚至梯度消失。并且初始化權(quán)重時不可太大。
- Sigmoid函數(shù)的輸出分布不是以0為中心分布的,在梯度下降過程中可能會存在梯度恒正或是恒負(fù)的情況出現(xiàn)。
import numpy as np
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-x))
def sigmoid_prime(z):
return sigmoid(z) * (1 - sigmoid(z))
2. tanh

數(shù)學(xué)形式:
tanh函數(shù)會將輸入映射到[-1,1]的范圍,較大的值會被映射為1,較小的值會被映射為-1。
缺點:類似于Sigmoid函數(shù),也具有一定的激活飽和性。
import numpy as np
def tanh(z):
return np.tanh(z)
def tanh_prime(z):
return 1 - np.square(tanh(z))
3. relu

數(shù)學(xué)形式
優(yōu)點:
- 計算速度快。求導(dǎo)簡單。
- 不再梯度彌散。ReLU函數(shù)不像Sigmoid函數(shù),不存在梯度飽和區(qū),幾乎不會造成梯度彌散。
- 減少過擬合。部分神經(jīng)元輸出可能為0,加大網(wǎng)絡(luò)稀疏性,減少過擬合。
缺點:初始化不佳會造成神經(jīng)元死亡。針對此問題提出了Leaky ReLU、PReLU和RReLU等變體。
import numpy as np
def relu(z):
return (np.abs(z) + z) / 2
def relu_prime(z):
return np.where(z > 0, 1, 0)
損失函數(shù)
當(dāng)我們的輸入數(shù)據(jù)經(jīng)過神經(jīng)網(wǎng)絡(luò),得到了一組輸出數(shù)據(jù)。我們想去衡量我們的模型的好壞、給我們的模型一個得分或者說是我們想要優(yōu)化的最終目標(biāo),便需要定義好損失函數(shù)。將我們的輸出值與真實值通過損失函數(shù)進(jìn)行計算,得到損失值(loss),為了使得模型更好,能夠與真實情況相擬合,所以我們需要找到一個適合的網(wǎng)絡(luò)權(quán)重使得輸出的loss最小。對于回歸問題最常使用的損失函數(shù)是均方誤差(Mean-Square Error,MSE),對于分類問題最常使用的是交叉熵(Cross Entropy),這里僅簡單介紹MSE。
均方誤差是指參數(shù)估計值與參數(shù)真值之差平方的期望值;
MSE可以評價數(shù)據(jù)的變化程度,MSE的值越小,說明預(yù)測模型描述實驗數(shù)據(jù)具有更好的精確度。
反向傳播
現(xiàn)在我們已經(jīng)了解了全連接神經(jīng)網(wǎng)絡(luò)的前向傳播和激活函數(shù)和其導(dǎo)數(shù)的數(shù)學(xué)表達(dá),下面我們要使用反向傳播算法進(jìn)行最優(yōu)參數(shù)(采用梯度下降法,可能造成局部最優(yōu))的求解。
由于神經(jīng)網(wǎng)絡(luò)結(jié)構(gòu)十分復(fù)雜,想要直接去求得權(quán)重的最優(yōu)解是不大可能的。所以采用迭代的思想進(jìn)行一步一步的權(quán)重更新,直到找到最佳的解。最常用的便是梯度下降法(gradient descent)。如果不了解梯度下降法,推薦觀看Andrew Ng的機器學(xué)習(xí)視頻。
接下來假設(shè)你已經(jīng)了解梯度下降法,現(xiàn)在我們來一起推導(dǎo)一下反向傳播算法的公式,了解整個過程。這里采用下圖簡單的例子作為示范。很容易地可以擴(kuò)展到任意寬度,任意深度的全連接網(wǎng)絡(luò)上去。

假設(shè)我們的神經(jīng)網(wǎng)絡(luò)是一個輸入層,有兩個神經(jīng)元;一個隱藏層,有兩個神經(jīng)元;一個輸出層,有一個神經(jīng)元。從圖可以看到一共有6個權(quán)重需要我們計算。
說明:接下來的激活函數(shù)都是sigmoid函數(shù),均以 f(·) 表示。小寫字母表示未經(jīng)激活函數(shù)的輸出,大寫字母表示通過激活函數(shù)的輸出值。
隱藏層到輸出層
數(shù)據(jù)通過神經(jīng)網(wǎng)絡(luò)得到了一個輸出 ,我們定義的損失函數(shù)為MSE,所以可以計算出當(dāng)前的loss作為總誤差(這里舉例為一個輸出神經(jīng)元,如果有多個輸出,總誤差加和即可)。最后輸出層這里我們不添加激活函數(shù),所以
。
有了總誤差,接下來我們就可以通過梯度下降法進(jìn)行權(quán)重的更新。先來看隱藏層到輸出層的權(quán)重 。
找到在前向傳播時,有關(guān) 的式子:
根據(jù)鏈?zhǔn)椒▌t求出 對于總誤差的偏導(dǎo):
同理可得:
對于偏置項 :
為了方便表示,我們把來自 的誤差表示為
,即:
整理后得到:
我們計算出來 的偏導(dǎo)之后,就可以進(jìn)行權(quán)重的更新了。(這里并不立刻更新,因前層進(jìn)行反向傳播時需要此層更新前的權(quán)重,下面會講)
這里的 為學(xué)習(xí)率。
輸入層到隱藏層
與隱藏層到輸出層類似,只不過有小小的差別。
對于 ,只拿
作為示范,其他的類似求解。
首先列出前向傳播時與 有關(guān)的公式:
從上式可以看出,我們需要先求出處的誤差,進(jìn)而求得
的梯度。
這里便是上面提到的需要使用更新前的隱藏層到輸出層的權(quán)重值
接下來便和隱藏層到輸出層的反向傳播沒有差別了,以 為例:
其他的權(quán)重和偏置項也根據(jù)公式進(jìn)行類似的計算,并進(jìn)行更新。
至此反向傳播便完成了,全部的權(quán)重得到了更新。下面我們根據(jù)上面的過程來編寫代碼。
CODE
Layer類
因為預(yù)計還要將CNN、RNN、LSTM等都采用numpy實現(xiàn)一遍,所以我們先定義一個Layer的基類。里面寫一些,所有層都需要的函數(shù),比如激活函數(shù)等。這里API形式仿照Keras。
from abc import abstractmethod
import numpy as np
class Layer(object):
def _activation(self, name, x):
"""
激活函數(shù)
:param name: 激活函數(shù)的名稱。
:param x: 激活函數(shù)的自變量。
:return: 返回激活函數(shù)計算得到的值
"""
if name == 'sigmoid':
return 1.0 / (1.0 + np.exp(-x))
elif name == 'tanh':
return np.tanh(x)
elif name == 'relu':
return (np.abs(x) + x) / 2
elif name == 'none': # 不使用激活函數(shù)
return x
else:
raise AttributeError("activation name wrong")
def _activation_prime(self, name, x):
if name == 'sigmoid':
return self._activation(name, x) * (1 - self._activation(name, x))
elif name == 'tanh':
return 1 - np.square(self._activation(name, x))
elif name == 'relu':
return np.where(x > 0, 1, 0)
elif name == 'none':
return 1
else:
raise AttributeError("activation name wrong")
@abstractmethod
def forward_propagation(self, **kwargs):
pass
@abstractmethod
def back_propagation(self, **kwargs):
pass
Dense層
接下來我們開始編寫Dense層。
from Layer import Layer
import numpy as np
class DenseLayer(Layer):
def __init__(self, shape, activation, name):
"""
Dense層初始化。
:param shape: 如輸入神經(jīng)元有2個,輸出神經(jīng)元有3個。那么shape = (2,3)
:param activation: 激活函數(shù)名稱
:param name: 當(dāng)前層的名稱
"""
super().__init__()
self.shape = shape
self.activation_name = activation
self.__name = name
self.__w = 2 * np.random.randn(self.shape[0], self.shape[1]) # 這里采用矩陣的隨機初始化
self.__b = np.random.randn(1, shape[1])
def forward_propagation(self, _input):
"""
Dense層的前向傳播實現(xiàn)
:param _input: 輸入的數(shù)據(jù),即前一層的輸出
:return: 通過激活函數(shù)后的輸出
"""
self.__input = _input
self.__output = self._activation(self.activation_name, self.__input.dot(self.__w) + self.__b)
return self.__output
def back_propagation(self, error, learning_rate):
"""
Dense層的反向傳播
:param error: 后一層傳播過來的誤差
:param learning_rate: 學(xué)習(xí)率
:return: 傳播給前一層的誤差
"""
o_delta = np.matrix(error * self._activation_prime(self.activation_name, self.__output))
w_delta = np.matrix(self.__input).T.dot(o_delta)
input_delta = o_delta.dot(self.__w.T)
self.__w -= w_delta * learning_rate
self.__b -= o_delta * learning_rate
return input_delta
Model類
接著寫一個Model類實現(xiàn)Keras的各種API
import numpy as np
class Model(object):
def __init__(self):
"""
簡單使用列表按順序存放各層
"""
self.layers = []
def add(self, layer):
"""
向模型中添加一層
:param layer: 添加的Layer
"""
self.layers.append(layer)
def fit(self, X, y, learning_rate, epochs):
"""
訓(xùn)練
:param X: 訓(xùn)練集數(shù)據(jù)
:param y: 訓(xùn)練集標(biāo)簽
:param learning_rate: 學(xué)習(xí)率
:param epochs: 全部數(shù)據(jù)集學(xué)習(xí)的輪次
"""
if self.__loss_function is None:
raise Exception("compile first")
# 前饋
for i in range(epochs):
loss = 0
for num in range(len(X)):
out = X[num]
for layer in self.layers:
out = layer.forward_propagation(out)
loss += self.__loss_function(out, y[num], True)
error = self.__loss_function(out, y[num], False)
for j in range(len(self.layers)):
index = len(self.layers) - j - 1
error = self.layers[index].back_propagation(error, learning_rate)
print("epochs {} / {} loss : {}".format(i + 1, epochs, loss/len(X)))
def compile(self, loss_function):
"""
編譯,目前僅設(shè)置損失函數(shù)
:param loss_function: 損失函數(shù)的名稱
"""
if loss_function == 'mse':
self.__loss_function = self.__mse
def __mse(self, output, y, forward):
"""
:param output: 預(yù)測值
:param y: 真實值
:param forward: 是否是前向傳播過程
:return: loss值
"""
if forward:
return np.squeeze(0.5 * ((output - y) ** 2))
else:
return output - y
def predict(self, X):
"""
結(jié)果預(yù)測
:param X: 測試集數(shù)據(jù)
:return: 對測試集數(shù)據(jù)的預(yù)測
"""
res = []
for num in range(len(X)):
out = X[num]
for layer in self.layers:
out = layer.forward_propagation(out)
res.append(out)
return np.np.squeeze(np.array(res))
Main
最后我們來寫一個主函數(shù)簡單擬合異或測試一下全連接網(wǎng)絡(luò)。
from Dense import DenseLayer
import Model
if __name__ == '__main__':
model = Model.Model()
X = np.array([
[1, 1],
[1, 0],
[0, 1],
[0, 0]
])
y = np.array([0, 1, 1, 0])
model.add(Dense((2, 3), 'sigmoid', 'dense1'))
model.add(Dense((3, 4), 'sigmoid', 'dense2'))
model.add(Dense((4, 1), 'none', 'output'))
model.compile('mse')
model.fit(X, y, 0.1, 1000)
print(model.predict([[1, 1], [1, 0]]))
epochs 1 / 1000 loss : 1.5461586301292716
epochs 2 / 1000 loss : 1.0010336204321242
epochs 3 / 1000 loss : 0.8421754635331838
epochs 4 / 1000 loss : 0.7311597301044074
epochs 5 / 1000 loss : 0.6428097142979868
epochs 6 / 1000 loss : 0.5709843947151808
epochs 7 / 1000 loss : 0.5122654038390013
epochs 8 / 1000 loss : 0.4640985740577866
epochs 9 / 1000 loss : 0.4244527616264729
epochs 10 / 1000 loss : 0.39169518752811794
···
epochs 995 / 1000 loss : 0.0018694401858458181
epochs 996 / 1000 loss : 0.0018245697992101736
epochs 997 / 1000 loss : 0.001780665685232114
epochs 998 / 1000 loss : 0.0017377108735277388
epochs 999 / 1000 loss : 0.0016956885636446625
epochs 1000 / 1000 loss : 0.001654582127688094
···
[0.0610148 0.98877437]
可見模型是能夠收斂,并且擬合非線性映射的。
如果你有某些疑問或是改進(jìn),歡迎留下你的評論。
TODO
現(xiàn)在我們實現(xiàn)了全連接神經(jīng)網(wǎng)絡(luò),在下一篇博文我們將會繼續(xù)推導(dǎo)和實現(xiàn)最為常用而且是最為復(fù)雜的卷積神經(jīng)網(wǎng)絡(luò)(CNN)。
參考內(nèi)容
感謝以下博客和網(wǎng)站
[1] 神經(jīng)網(wǎng)絡(luò) - Ufldl
[2] 大白話講解BP算法
[3] Keras