Python · NN(一) · 神經網路入門

(這裡是本章會用到的 Jupyter Notebook 地址)

(話說知乎把超鏈接功能刪了、把公式編輯器削了【只能輸單行 Tex 代碼而且各種詭異 Bug】是想鬧哪樣……)

雖然之前寫過神經網路相關的文章,但彼時是本專欄剛起步的階段,我基本是在率(瞎)性(J)地(B)寫,文筆清奇邏輯混亂,所以還是重新整理一下這一部分的東西

畢竟神經網路還是很重要的……

等重構版弄完之後,我大概會把之前的那些玩(黑)意(歷)兒(史)刪掉吧…… ( σ"ω")σ

將感知機應用於多分類任務

我們之前在 Python · SVM(一)· 感知機 裡面介紹感知機時,用的是這麼一個公式:y_{pred}=wcdot x+b,然後樣本中的y要求只能取pm1。這在二分類任務時當然沒問題,但是到多分類時就會有些問題。雖說我們是可以用各種方法讓二分類模型去做多分類任務的,不過那些方法普遍都比較麻煩。為了讓我們的模型天然適應於多分類任務,我們通常會將樣本中的y取為	ext{One-Hot}向量,亦即

  • yin第 k 類Leftrightarrow y除了第 k 位為 1 以外、其餘位都是 0

此時我們的感知機就會變成這樣(以 N 個擁有 n 維特徵的樣本的 K 分類任務為例;為簡潔,省略了偏置量 b):

(看在我純鼠繪的份上原諒我畫得那麼丑吧 | ω?`))

此時模型的表示會變為y_{pred}=Xw。注意到我們原來輸出的是一個數,在改造後輸出的則是一個 K 維向量。也正因此,我們不能簡單地沿用之前定義的損失函數(L=|yy_{pred}|_+)、而應該定義一個新的損失函數

由於我們的目的是讓模型輸出的向量y_{pred}和真實的(	ext{One-Hot})標籤向量y「越近越好」,而「距離」是一個天然的衡量「遠近」的東西,所以用歐氏距離來定義損失函數是比較自然的。具體而言,我們可以把損失函數定義為L=|y_{pred}-y|^2

在有了損失之後就可以求導了。需要指出的是,雖然我接下來寫的公式看上去挺顯然,但由於我們進行的是矩陣求導工作,所以它們背後真正的邏輯其實沒那麼顯然。有興趣的觀眾老爺可以看看這篇文章,這裡就先直接給出結果,細節會附在文末:

frac{partial L}{partial w}=frac{partial y_{pred}}{partial w}frac{partial L}{partial y_{pred}}=2X^T(y_{pred}-y)

利用它,我們就能寫出相應的梯度下降訓練演算法了:

import numpy as npclass MultiPerceptron: def __init__(self): # 注意這裡的 self._w 將來會是(n x K 的)矩陣 self._w = None def fit(self, x, y, lr=1e-3, epoch=1000): x, y = np.asarray(x, np.float32), np.asarray(y, np.float32) # x.shape[1] 即為 n、y.shape[1] 即為 K self._w = np.zeros([x.shape[1], y.shape[1]]) for _ in range(epoch): # 依公式進行梯度下降 y_pred = x.dot(self._w) dw = 2 * x.T.dot(y_pred - y) self._w -= lr * dw def predict(self, x): # 依公式計算模型輸出向量 y_pred = np.asarray(x, np.float32).dot(self._w) # 預測類別時對輸出的 K 維向量用 argmax 即可 return np.argmax(y_pred, axis=1).astype(np.float32)

該模型的表現如下:

此時正確率為 50% 左右。可以看到模型輸出中基本沒有對角線上的那一類(而這一類恰恰是最多的),這是因為將感知機拓展為多類模型並不會改變它是線性模型的本質、所以其分類效果仍然是線性的,從而它無法擬合出對角線那一類的邊界

從感知機到神經網路

那麼怎樣才能把感知機變成一個非線性的模型呢?我在 Python · SVM(三)· 核方法 中曾介紹過核方法這看上去非常高端大氣的東西,這篇文章的話就主要介紹另一種思路。具體而言:

  • 現有的感知機只有兩層(輸入層和輸出層),是否能多加一些層?
  • 核方法從直觀上來說,是利用滿足一定條件的核函數將樣本空間映射到高維空間;如果我放寬條件、使用一些一般的比較好的函數,是否也能起到一定的效果?

基於這兩個想法,我們可以把上面畫過的感知機模型的結構弄成下圖這種結構:

其中,我們通常(假設共有 N 個樣本):

  • varphi為「激活函數
  • w_1w_2權值矩陣
  • 稱往中間加的那些層為「隱藏層」;為簡潔,此後我們討論時認為只加了一層隱藏層,多層的情況會在下一篇文章討論
  • 稱圖中每個圈兒為「神經元」。以上圖為例的話:
    • 輸入層有 n 個神經元
    • 隱藏層有 m 個神經元
    • 輸出層有 K 個神經元

然後,激活函數其實就是上文所說的一般的比較好的函數;常用激活函數的相關介紹我會附在文末,這裡就暫時認定激活函數為我們之前在 SVM 處就見過的 ReLU,亦即認定varphi(x)=|x|_+=max(0, x)

這個結構看上去很強大,但求導求起來卻也會麻煩不少(損失函數仍取L=|y_{pred}-y|^2)(知乎現在這公式編輯器實在受不了了所以我就用 Word 了……仍是只給出結果並將細節附在文末):

其中,「 * 」表示乘法的 element-wise 操作(或者專業一點的話,叫 Hadamard 乘積)、ReLU"表示對 ReLU 函數進行求導。由於當varphi為 ReLU 時我們有varphi(x)=|x|_+=max(0, x),所以其求導也是非常簡單的:

利用這兩個求導公式,相應的梯度下降演算法是比較好實現的:

class NaiveNN: def __init__(self): # 這裡的 self._ws 將會是一個存儲著兩個權值矩陣的列表 self._ws = None @staticmethod def relu(x): # 利用 numpy 相應函數直接寫出 ReLU 的實現 return np.maximum(0, x) # hidden_dim 即為隱藏層神經元個數 m def fit(self, x, y, hidden_dim=32, lr=1e-5, epoch=1000): input_dim, output_dim = x.shape[1], y.shape[1] # 隨機初始化權值矩陣 self._ws = [ np.random.random([input_dim, hidden_dim]), np.random.random([hidden_dim, output_dim]) ] # 定義一個列表存儲訓練過程中的損失 losses = [] for _ in range(epoch): # 依公式算出各個值 h = x.dot(self._ws[0]); h_relu = NaiveNN.relu(h) y_pred = h_relu.dot(self._ws[1]) # 利用 np.linalg.norm 算出損失 losses.append(np.linalg.norm(y_pred - y, ord="fro")) # 依公式算出各個梯度 d1 = 2 * (y_pred - y) dw2 = h_relu.T.dot(d1) dw1 = x.T.dot(d1.dot(self._ws[1].T) * (h_relu != 0)) # 走一步梯度下降 self._ws[0] -= lr * dw1; self._ws[1] -= lr * dw2 # 把諸模型損失返回 return losses def predict(self, x): h = x.dot(self._ws[0]); h_relu = NaiveNN.relu(h) # 用 argmax 預測類別 return np.argmax(h_relu.dot(self._ws[1]), axis=1)

該模型的表現如下:

雖然還是比較差(準確率 70% 左右),但已經有模有樣了

使用 Softmax + Cross Entropy

可能已經有觀眾老爺發現,我把上面這個模型的訓練速率的默認值調到了10^{-5},這是因為稍大一點的訓練速率都會使模型直接爆炸。這是因為我們沒有對最後一層做變換、而是直接簡單粗暴地用了y_{pred}=H_{ReLU}w_2。這導致最終模型的輸出很有可能突破天際,這當然不是我們想看到的

考慮到標籤是	ext{One-Hot}向量,換一個角度來看的話,它其實也是一個概率分布向量。那麼我們是否可以將模型的輸出也變成一個概率向量呢?事實上,我們耳熟能詳的 Softmax 正是干這活兒的,具體而言,假設現在有一個向量y=(y_1,y_2,...,y_K)^T,那麼就有:

不難看出這是個概率向量,且從直觀上來看頗為合理。在真正應用 Softmax 會有一個提高數值穩定性的小技巧,細節會附在文末,這裡就暫時按下

於是在應用了 Softmax 之後,我們模型的輸出就變成一個概率向量了。誠然此時仍然能用歐氏距離作為損失函數,不過一種普遍更優的做法是使用 Cross Entropy(交叉熵)作為損失函數。具體而言,兩個隨機變數y(真值)、y_{pred}(預測值)的交叉熵為:

交叉熵擁有如下兩個性質:

  • 當真值為 0(y=0)時,交叉熵其實就化為了-log(1-y_{pred}),此時預測值越接近 0、交叉熵就越接近 0,反之若預測值趨於 1、交叉熵就會趨於無窮
  • 當真值為 1(y=1)時,交叉熵其實就化為了-log y_{pred},此時預測值越接近 1、交叉熵就越接近 0,反之若預測值趨於 0、交叉熵就會趨於無窮

所以拿交叉熵作為損失函數是合理的。真正應用交叉熵時同樣會有提高數值穩定性的小技巧——在 log 裡面放一個小值以避免出現 log 0 的情況:

在加了這兩個東西之後,我們就要進行求導了。雖說求導過程比較繁複,但令人驚喜的是,最終結果和之前的結果是幾乎一致的,區別只在於倍數(推導過程參見文末):

所以相應的實現也幾乎一致:

class NN: def __init__(self, ws=None): self._ws = ws @staticmethod def relu(x): return np.maximum(0, x) @staticmethod def softmax(x): exp_x = np.exp(x - np.max(x, axis=1, keepdims=True)) return exp_x / np.sum(exp_x, axis=1, keepdims=True) @staticmethod def cross_entropy(y_pred, y_true): return -np.average( y * np.log(np.maximum(y_pred, 1e-12)) + (1 - y) * np.log(np.maximum(1 - y_pred, 1e-12)) ) def fit(self, x, y, hidden_dim=4, lr=1e-3, epoch=1000): input_dim, output_dim = x.shape[1], y.shape[1] if self._ws is None: self._ws = [ np.random.random([input_dim, hidden_dim]), np.random.random([hidden_dim, output_dim]) ] losses = [] for _ in range(epoch): h = x.dot(self._ws[0]); h_relu = NN.relu(h) y_pred = NN.softmax(h_relu.dot(self._ws[1])) losses.append(NN.cross_entropy(y_pred, y)) d1 = y_pred - y dw2 = h_relu.T.dot(d1) dw1 = x.T.dot(d1.dot(self._ws[1].T) * (h_relu != 0)) self._ws[0] -= lr * dw1; self._ws[1] -= lr * dw2 return losses def predict(self, x): h = x.dot(self._ws[0]); h_relu = NaiveNN.relu(h) # 由於 Softmax 不影響 argmax 的結果,所以這裡直接 argmax 即可 return np.argmax(h_relu.dot(self._ws[1]), axis=1)

該模型的表現如下:

雖說仍不完美,但它和不使用 Softmax + Cross Entropy 的模型相比,有這麼兩個優勢:

  • 訓練速率可以調得更大(10^{-4}vs10^{-5}),這意味著該模型沒那麼容易爆炸(什麼鬼)
  • 模型的訓練更加穩定,亦即每次訓練出來的結果都差不多。反觀之前的模型,我所給出的模型表現其實是精挑細選出來的;在一般情況下,其表現其實是類似於這樣的(這告訴我們,一個好的結果很有可能是由無數 sb 結果堆積出來的……):

相關數學理論

1)常見激活函數

A、Sigmoid

B、Tanh

C、ReLU

D、ELU

E、Softplus

以及最近出了一個叫 SELU 的激活函數,論文整整有 102 頁……感興趣的觀眾老爺們可以參見[1706.02515] Self-Normalizing Neural Networks

2)神經網路中導數的計算

為了書寫簡潔,接下來我們會用矩陣求導的技巧來進行計算;不過如果覺得看著太繞的話,建議還是參照這篇文章、依定義逐元素求導(事實上我也經常繞不過來……)

由簡入繁,我們先來看多分類感知機的求導過程。我們之前曾說過多分類感知機可以寫成y_{pred}=Xw、其中(假設有 N 個樣本):

  • XN	imes n的矩陣
  • wn	imes K的矩陣、從而y_{pred}N	imes K的矩陣
  • 損失L=|y_{pred}-y|^2是一個N	imes1的向量

不少人在嘗試求 frac{partial L}{partial w} 時,會以為需要用向量對矩陣求導的法則,雖然不能說是錯的,卻可能會把問題變複雜。事實上,多分類感知機的本質是對隨機向量old{x}的某個採樣x進行預測,X只不過是一個多次採樣後產生的樣本矩陣而已。因此,多分類感知機的本質其實是	ilde{y}=Wold x、其中:

  • xn	imes1的向量
  • WK	imes n的權值矩陣(事實上,W=w^T)、從而	ilde{y}K	imes1的隨機向量
  • 損失L=|	ilde{y}-y|^2是一個數

於是求導過程就化簡為標量對矩陣的求導了:

由此可知frac{partial L}{partial W}=2(	ilde y-y)x^T。在求出這個特殊情形之後,應該如何把它拓展到樣本矩陣的情形呢?雖說嚴謹的數學敘述會比較麻煩(需要用到矩陣的向量化【又稱「拉直」】之類的),但我們可以這樣直觀地理解:

  • 當樣本從一個變成多個時,權值矩陣的導數理應是從一個變成多個的加總

因此我們只需利用矩陣乘法完成這個加總的過程即可。注意到我們可以直觀地寫出:

且「加總」即為frac{partial L}{partial W}=sum_{i=1}^N2(	ilde y_i-y_i)x_i^T,從而我們可以直觀地寫出frac{partial L}{partial W}=2(y_{pred}-y)^TX

注意到我們有w=W^T,所以就有frac{partial L}{partial w}=2X^T(y_{pred}-y)

單隱藏層的、沒有應用 Softmax + Cross Entropy 的神經網路的推導是類似的,此時:

  • h=W_1x,其中xW_1h的形狀分別是n	imes1m	imes nm	imes 1
  • 	ilde y=W_2varphi(h),其中varphi是 ReLU,W_2	ilde y的形狀分別是K	imes mK	imes1
  • L=(	ilde y-y)^T(	ilde y-y)

於是

以及

3)Softmax + Cross Entropy

在應用了 Softmax + Cross Entropy 之後,上面求導過程中受影響的其實只有frac{partial L}{partial	ilde y}這一項,所以只需看這一項如何變化的即可

在展開討論之前,需要先做一些符號約定(假設原模型輸出向量為	ilde y=(	ilde y_1, 	ilde y_2,...,	ilde y_K)^T):

  • phi=(phi_1,phi_2,...,phi_K)^T,其中phi_i=e^{	ilde y_i}
  • 設 Softmax 後的模型輸出向量為hat y=(hat y_1,hat y_2,...hat y_K)^T,那麼依定義就有

  • 設交叉熵對應的損失函數為hat L,則有hat L=-[ylog hat y+(1-y)log(1-hat y)]

從而可知dhat L=	ext{tr}left(frac{partialhat L}{partialhat y}^Tdhat y
ight),其中

亦即

本文我們主要討論了如何將感知機應用於多分類任務,並通過直觀的思想——加深感知機的層次和應用激活函數來得到更強力的模型(神經網路)。此外,我們還討論了如何應用 Softmax + Cross Entropy 來讓模型變得更加穩定。然而我們討論的範圍仍局限於單隱藏層和 ReLU 激活函數,下一篇文章我們會介紹更一般的情形,並通過一種方式來直觀說明神經網路的強大

希望觀眾老爺們能夠喜歡~


推薦閱讀:

神經網路的學習 / 訓練過程
第四周筆記:神經網路是什麼
卷積神經網路(CNN)的參數優化方法
[Matlab]BP神經網路預測[1]

TAG:Python | 机器学习 | 神经网络 |