Python · CNN(一)· 層結構

(這裡是最終成品的 GitHub 地址)

(這裡是本章用到的 GitHub 地址)

========== 寫在前面的話 ==========

其實在 4 個月之前我寫過一篇叫「Python · 神經網路(八)· ConvLayer」的文章,不過現在看回去覺得寫的有點太概括性了;如果直接往下寫的話,估計觀眾老爺們(以及我自己)的邏輯都理不順 _(:з」∠)_

所以我打算重寫一次,而且這次會對之前 NN 系列的文章做一個匯總性說明;換句話說,我會從頭開始講如何實現 CNN 而不是接著 NN 的邏輯來講(這也是為什麼我沒有接著用「神經網路」這個系列名而是開了個新的「CNN」系列) _(:з」∠)_

這意味著本文(及接下來的 CNN 系列)會巨長無比,畢竟我會試圖把兩三百行的東西一次性講清楚 _(:з」∠)_

如果覺得這些都無所謂並願意看的話,我會覺得很開心的 _(:з」∠)_

一些數學基礎:

數學 · 神經網路(一)· 前向傳導

數學 · 神經網路(二)· BP(反向傳播)

數學 · 神經網路(三)· 損失函數

數學 · CNN · 從 NN 到 CNN

========== 分割線的說 ==========

往簡單里說、CNN 只是多了卷積層、池化層和 FC 的 NN 而已,雖然卷積、池化對應的前向傳導演算法和反向傳播演算法的高效實現都很不平凡,但得益於 Tensorflow 的強大、我們可以在僅僅知道它們思想的前提下進行相應的實現,因為 Tensorflow 能夠幫我們處理所有數學與技術上的細節(Tensorflow 的應用式入門教程可以參見這裡)

實現普通層

我們在Python · 神經網路(一)· 層和Python · 神經網路(二)· 層裡面非常瑣碎地說明了如何實現 Layer 結構,這裡我們就詳盡地把整個實現捋一捋。鑒於 Tensorflow 能夠自動獲取梯度、同時考慮到要擴展出 CNN 的功能,我們需要實現如下功能:

  • 對於激活函數,只用定義其原始形式、不必定義其導函數形式
  • 解決特殊層結構(Dropout、Normalize 等等)的實現問題
  • 要考慮當前層為 FC(全連接層)時的表現
  • 讓用戶可以選擇是否給 Layer 加偏置量

其中的第四點可能有些讓人不明所以:要知道偏置量可是對破壞對稱性是很重要的,為什麼要讓用戶選擇是否使用偏置量呢?這主要是因為特殊層結構中 Normalize 的特殊性會使偏置量顯得冗餘。具體細節會在後文討論特殊層結構處進行說明,這裡就暫時按下不表

以下是 Layer 結構基類的具體代碼:

import numpy as npimport tensorflow as tffrom math import ceilclass Layer: """ 初始化結構 self.shape:記錄該Layer和上個Layer所含神經元的個數,具體而言: self.shape[0] = 上個Layer所含神經元的個數 self.shape[1] = 該Layer所含神經元的個數 self.is_fc、self.is_sub_layer:記錄該Layer是否為FC、特殊層結構的屬性 self.apply_bias:記錄是否對該Layer加偏置量的屬性 """ def __init__(self, shape, **kwargs): self.shape = shape self.is_fc = self.is_sub_layer = False self.apply_bias = kwargs.get("apply_bias", True) def __str__(self): return self.__class__.__name__ def __repr__(self): return str(self) @property def name(self): return str(self) @property def root(self): return self # 定義兼容特殊層結構和CNN的、前向傳導演算法的封裝 def activate(self, x, w, bias=None, predict=False): # 如果當前層是FC、就需要先將輸入「鋪平」 if self.is_fc: x = tf.reshape(x, [-1, int(np.prod(x.get_shape()[1:]))]) # 如果是特殊的層結構、就調用相應的方法獲得結果 if self.is_sub_layer: return self._activate(x, predict) # 如果不加偏置量的話、就只進行矩陣相乘和激活函數的作用 if not self.apply_bias: return self._activate(tf.matmul(x, w), predict) # 否則就進行「最正常的」前向傳導演算法 return self._activate(tf.matmul(x, w) + bias, predict) # 前向傳導演算法的核心、留待子類定義 def _activate(self, x, predict): pass

注意到我們前向傳導演算法中有一項「predict」參數,這主要是因為特殊層結構的訓練過程和預測過程表現通常都會不一樣、所以要加一個標註。該標註的具體意義會在後文進行特殊層結構 SubLayer 的相關說明時體現出來、這裡暫時按下不表

在實現好基類後、就可以實現具體要用在神經網路中的 Layer 了。以 Sigmoid 激活函數對應的 Layer 為例:

class Sigmoid(Layer): def _activate(self, x, predict): return tf.nn.sigmoid(x)

得益於 Tensorflow 框架的強大(你除了這句話就沒別的話說了嗎……)、我們甚至連激活函數的形式都無需手寫,因為它已經幫我們封裝好了(事實上、絕大多數常用的激活函數在 Tensorflow 裡面都有封裝)

實現特殊層

我們在Python · 神經網路(三*)· 網路這裡曾經簡要介紹過特殊層 SubLayer 的思想,這裡我們將介紹如何利用 Tensorflow 框架實現它,同時也會對十分常用的兩種 SubLayer —— Dropout 和 Normalize 做深入一些的介紹

先來看看應該如何定義 SubLayer 的基類:

# 讓SubLayer繼承Layer以合理復用代碼class SubLayer(Layer): """ 初始化結構 self.shape:和Layer相應屬性意義一致 self.parent:記錄該Layer的父層的屬性 self.description:用於可視化的屬性,記錄著對該SubLayer的「描述」 """ def __init__(self, parent, shape): Layer.__init__(self, shape) self.parent = parent self.description = "" # 輔助獲取Root Layer的property @property def root(self): _root = self.parent while _root.parent: _root = _root.parent return _root

可以看到,得益於 Tensorflow 框架(Tensorflow 就是很厲害嘛……),本來難以處理的SubLayer 的實現變得非常簡潔清晰。在實現好基類後、就可以實現具體要用在神經網路中的 SubLayer 了,先來看 Dropout:

class Dropout(SubLayer): # self._prob:訓練過程中每個神經元被「留下」的概率 def __init__(self, parent, shape, drop_prob=0.5): # 神經元被Drop的概率必須大於等於0和小於1 if drop_prob < 0 or drop_prob >= 1: raise ValueError( "(Dropout) Probability of Dropout should be a positive float smaller than 1") SubLayer.__init__(self, parent, shape) # 被「留下」的概率自然是1-被Drop的概率 self._prob = tf.constant(1 - drop_prob, dtype=tf.float32) self.description = "(Drop prob: {})".format(drop_prob) def _activate(self, x, predict): # 如果是在訓練過程,那麼就按照設定的、被「留下」的概率進行Dropout if not predict: return tf.nn.dropout(x, self._prob) # 如果是在預測過程,那麼直接返回輸入值即可 return x

Dropout 的詳細說明自然是看原 paper 最好,這裡我就大概翻譯、總結一下主要內容。Dropout 的核心思想在於提高模型的泛化能力:它會在每次迭代中依概率去掉對應 Layer 的某些神經元,從而每次迭代中訓練的都是一個小的神經網路。這個過程可以通過下圖進行說明:

上圖所示的即為當 drop_prob 為 50%(我們所設的默認值)時、Dropout 的一種可能的表現。左圖所示為原網路、右圖所示的為 Dropout 後的網路,可以看到神經元 a、b、e、g、j 都被 Drop 了

Dropout 過程的合理性需要概率論上一些理論的支撐,不過鑒於 Tensorflow 框架有封裝好的相應函數、我們就不深入介紹其具體的數學原理而僅僅說明其直觀(以 drop_prob 為 50%為例,其餘 drop_prob 的情況是同理的):

  • 在訓練過程中,由於 Dropout 後留下來的神經元可以理解為「在 50%死亡概率下倖存」的神經元,所以給將它們對應的輸出進行「增幅」是合理的。具體而言,假設一個神經元n_i的輸出本來是o_i,那麼如果 Dropout 後它被留下來了的話、其輸出就應該變成o_i	imesfrac1{50\%}=2o_i(換句話說、應該讓帶 Dropout 的期望輸出和原輸出一致:對於任一個神經元n_i,設 drop_prob 為 p 而其原輸出為o_i,那麼當帶 Dropout 的輸出為o_i	imesfrac1p時、n_i的期望輸出即為p	imes o_i	imesfrac1p=o_i
  • 由於在訓練時我們保證了神經網路的期望輸出不變、所以在預測過程中我們還是應該讓整個網路一起進行預測而不進行 Dropout(關於這一點,原論文似乎也表示這是一種「經試驗證明行之有效」的辦法而沒有給出具體的、原理層面的說明)

Normalize 說起來有點長,所以我開了一個單獨的章節來說(數學 · 神經網路(四)· Normalize)。下面就直接看看如何實現它:

class Normalize(SubLayer): """ 初始化結構 self._eps:記錄增強數值穩定性所用的小值的屬性 self._activation:記錄自身的激活函數的屬性,主要是為了兼容圖7.17 A的情況 self.tf_rm、self.tf_rv:記錄μ_run、σ_run^2的屬性 self.tf_gamma、self.tf_beta:記錄γ、β的屬性 self._momentum:記錄動量值m的屬性 """ def __init__(self, parent, shape, activation="Identical", eps=1e-8, momentum=0.9): SubLayer.__init__(self, parent, shape) self._eps, self._activation = eps, activation self.tf_rm = self.tf_rv = None self.tf_gamma = tf.Variable(tf.ones(self.shape[1]), name="norm_scale") self.tf_beta = tf.Variable(tf.zeros(self.shape[1]), name="norm_beta") self._momentum = momentum self.description = "(eps: {}, momentum: {})".format(eps, momentum) def _activate(self, x, predict): # 若μ_run、σ_run^2還未初始化,則根據輸入x進行相應的初始化 if self.tf_rm is None or self.tf_rv is None: shape = x.get_shape()[-1] self.tf_rm = tf.Variable(tf.zeros(shape), trainable=False, name="norm_mean") self.tf_rv = tf.Variable(tf.ones(shape), trainable=False, name="norm_var") if not predict: # 利用Tensorflow相應函數計算當前Batch的舉止、方差 _sm, _sv = tf.nn.moments(x, list(range(len(x.get_shape()) - 1))) _rm = tf.assign( self.tf_rm, self._momentum * self.tf_rm + (1 - self._momentum) * _sm) _rv = tf.assign( self.tf_rv, self._momentum * self.tf_rv + (1 - self._momentum) * _sv) # 利用Tensorflow相應函數直接得到Batch Normalization的結果 with tf.control_dependencies([_rm, _rv]): _norm = tf.nn.batch_normalization( x, _sm, _sv, self.tf_beta, self.tf_gamma, self._eps) else: _norm = tf.nn.batch_normalization( x, self.tf_rm, self.tf_rv, self.tf_beta, self.tf_gamma, self._eps) # 如果指定了激活函數、就再用相應激活函數作用在BN結果上以得到最終結果 # 這裡只定義了ReLU和Sigmoid兩種,如有需要可以很方便地進行拓展 if self._activation == "ReLU": return tf.nn.relu(_norm) if self._activation == "Sigmoid": return tf.nn.sigmoid(_norm) return _norm

實現損失層

我們在Python · 神經網路(五)· Cost & Optimizer里曾經說明過如何實現損失層,這裡就簡單地重複一下:

# 定義一個簡單的基類class CostLayer(Layer): # 定義一個方法以獲取損失值 def calculate(self, y, y_pred): return self._activate(y_pred, y)# 定義Cross Entropy對應的CostLayer(整合了Softmax變換)class CrossEntropy(CostLayer): def _activate(self, x, y): return tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=x, labels=y))# 定義MSE準則對應的CostLayerclass MSE(CostLayer): def _activate(self, x, y): return tf.reduce_mean(tf.square(x - y))

我自己用 Numpy 寫的話,相同功能要寫那麼個 113 行,然後用 Tensorflow 的話 15 行就行了……由此可窺見 Tensorflow 框架的強大

(話說我這麼賣力地安利 Tensorflow,Google 是不是應該給我些廣告費什麼的)(喂

實現卷積層

回憶我們說過的卷積層和普通層的性質、不難發現它們的表現極其相似,區別大體上來說只在於如下三點(以下我們用u^{(i)}v^{(i)}phi_i表示第 i 層的輸入、輸出、激活函數):

  • 普通層自身對數據的處理只有「激活」(v^{(i)}=phi_ileft( u^{(i)}
ight))這一個步驟,層與層之間的數據傳遞則是通過權值矩陣、偏置量(w^{(i)}b^{(i)})和線性變換(u^{(i+1)}=v^{(i)}	imes w^{(i)}+b^{(i)})來完成的;卷積層自身對數據的處理則多了「卷積」這個步驟(通常來說是先卷積再激活:v^{(i)}=phi_ileft( 	ext{conv}left( u^{(i)}
ight)
ight))、同時層與層之間的數據傳遞是直接傳遞的(u^{(i+1)}=v^{(i)}
  • 卷積層自身多了 Kernel 這個屬性並因此帶來了諸如 Stride、Padding 等屬性,不過與此同時、卷積層之間沒有權值矩陣
  • 卷積層和普通層的 shape 屬性記錄的東西不同,具體而言:
    • 普通層的 shape 記錄著上個 Layer 和該 Layer 所含神經元的個數
    • 卷積層的 shape 記錄著上個卷積層的輸出和該卷積層的 Kernel 的信息(注意卷積層的上一層必定還是卷積層)

接下來就看看具體實現:

class ConvLayer(Layer): """ 初始化結構 self.shape:記錄著上個卷積層的輸出和該Layer的Kernel的信息,具體而言: self.shape[0] = 上個卷積層的輸出的形狀(頻道數×高×寬) 常簡記為self.shape[0] =(c,h_old,w_old) self.shape[1] = 該卷積層Kernel的信息(Kernel數×高×寬) 常簡記為self.shape[1] =(f,h_new,w_new) self.stride、self.padding:記錄Stride、Padding的屬性 self.parent:記錄父層的屬性 """ def __init__(self, shape, stride=1, padding="SAME", parent=None): if parent is not None: _parent = parent.root if parent.is_sub_layer else parent shape = _parent.shape Layer.__init__(self, shape) self.stride = stride # 利用Tensorflow裡面對Padding功能的封裝、定義self.padding屬性 if isinstance(padding, str): # "VALID"意味著輸出的高、寬會受Kernel的高、寬影響,具體公式後面會說 if padding.upper() == "VALID": self.padding = 0 self.pad_flag = "VALID" # "SAME"意味著輸出的高、寬與Kernel的高、寬無關、只受Stride的影響 else: self.padding = self.pad_flag = "SAME" # 如果輸入了一個整數、那麼就按照VALID情形設置Padding相關的屬性 else: self.padding = int(padding) self.pad_flag = "VALID" self.parent = parent if len(shape) == 1: self.n_channels = self.n_filters = self.out_h = self.out_w = None else: self.feed_shape(shape) # 定義一個處理shape屬性的方法 def feed_shape(self, shape): self.shape = shape self.n_channels, height, width = shape[0] self.n_filters, filter_height, filter_width = shape[1] # 根據Padding的相關信息、計算輸出的高、寬 if self.pad_flag == "VALID": self.out_h = ceil((height - filter_height + 1) / self.stride) self.out_w = ceil((width - filter_width + 1) / self.stride) else: self.out_h = ceil(height / self.stride) self.out_w = ceil(width / self.stride)

上述代碼的最後幾行對應著下述兩個公式、這兩個公式在 Tensorflow 裡面有著直接對應的實現:

  • 當 Padding 設置為 VALID 時,輸出的高、寬分別為:

    h^{out}=leftlceilfrac{(h^{old}-h^{new}+1)}{	ext{stride}}
ight
ceil,  w^{out}=leftlceilfrac{(w^{old}-w^{new}+1)}{	ext{stride}}
ight
ceil

    其中,符號「lceil 
ceil」代表著「向上取整」,stride 代表著步長

  • 當 Padding 設置為 SAME 時,輸出的高、寬分別為:

    h^{out}=leftlceilfrac{h^{old}}{	ext{stride}}
ight
ceil,  w^{out}=leftlceilfrac{w^{old}}{	ext{stride}}
ight
ceil

同時不難看出、上述代碼其實沒有把 CNN 的前向傳導演算法囊括進去,這是因為考慮到卷積層會利用到普通層的激活函數、所以期望能夠合理復用代碼。所以期望能夠把上述代碼定義的 ConvLayer 和前文重寫的 Layer 整合在一起以成為具體用在 CNN 中的卷積層,為此我們需要利用到 Python 中一項比較高級的技術——元類(元類的介紹可以參見這裡):

class ConvLayerMeta(type): def __new__(mcs, *args, **kwargs): name, bases, attr = args[:3] # 規定繼承的順序為ConvLayer→Layer conv_layer, layer = bases def __init__(self, shape, stride=1, padding="SAME"): conv_layer.__init__(self, shape, stride, padding) # 利用Tensorflow的相應函數定義計算卷積的方法 def _conv(self, x, w): return tf.nn.conv2d(x, w, strides=[self.stride] * 4, padding=self.pad_flag) # 依次進行卷積、激活的步驟 def _activate(self, x, w, bias, predict): res = self._conv(x, w) + bias return layer._activate(self, res, predict) # 在正式進行前向傳導演算法之前、先要利用Tensorflow相應函數進行Padding def activate(self, x, w, bias=None, predict=False): if self.pad_flag == "VALID" and self.padding > 0: _pad = [self.padding] * 2 x = tf.pad(x, [[0, 0], _pad, _pad, [0, 0]], "CONSTANT") return _activate(self, x, w, bias, predict) # 將打包好的類返回 for key, value in locals().items(): if str(value).find("function") >= 0: attr[key] = value return type(name, bases, attr)

在定義好基類和元類後、定義實際應用在 CNN 中的卷積層就非常簡潔了。以在深度學習中應用最廣泛的 ReLU 卷積層為例:

class ConvReLU(ConvLayer, ReLU, metaclass=ConvLayerMeta): pass

實現池化層

池化層比起卷積層而言要更簡單一點:對於最常見的兩種池化——極大池化和平均池化而言,它們所做的只是取輸入的極大值和均值而已、本身並沒有可以更新的參數。是故對池化層而言,我們無需維護其 Kernel、而只用定義相應的池化方法(極大、平均)即可,因此我們要求用戶在調用池化層時、只提供「高」和「寬」而不提供「Kernel 個數」

注意:Kernel 個數從數值上來說與輸出頻道個數一致,所以對於池化層的實現而言、我們應該直接用輸入頻道數來賦值 Kernel 數,因為池化不會改變數據的頻道數

class ConvPoolLayer(ConvLayer): def feed_shape(self, shape): shape = (shape[0], (shape[0][0], *shape[1])) ConvLayer.feed_shape(self, shape) def activate(self, x, w, bias=None, predict=False): pool_height, pool_width = self.shape[1][1:] # 處理Padding if self.pad_flag == "VALID" and self.padding > 0: _pad = [self.padding] * 2 x = tf.pad(x, [[0, 0], _pad, _pad, [0, 0]], "CONSTANT") # 利用self._activate方法進行池化 return self._activate(None)( x, ksize=[1, pool_height, pool_width, 1], strides=[1, self.stride, self.stride, 1], padding=self.pad_flag) def _activate(self, x, *args): pass

同樣的,由於 Tensorflow 已經幫助我們做好了封裝、我們可以直接調用相應的函數來完成極大池化和平均池化的實現:

# 實現極大池化class MaxPool(ConvPoolLayer): def _activate(self, x, *args): return tf.nn.max_pool# 實現平均池化class AvgPool(ConvPoolLayer): def _activate(self, x, *args): return tf.nn.avg_pool

實現 CNN 中的特殊層結構

在 CNN 中同樣有著 Dropout 和 Normalize 這兩種特殊層結構。它們的表現和 NN 中相應特殊層結構的表現是完全一致的,區別只在於作用的對象不同

我們知道,CNN 每一層數據的維度要比 NN 中每一層數據的維度多一維:一個典型的 NN 中每一層的數據通常是N	imes p	imes q的,而 CNN 則通常是N	imes p	imes q	imes r的、其中 r 是當前數據的頻道數。為了讓適用於 NN 的特殊層結構適配於 CNN,一個自然而合理的做法就是將 r 個頻道的數據當做一個整體來處理、或說將 CNN 中 r 個頻道的數據放在一起並視為 NN 中的一個神經元,這樣做的話就能通過簡易的封裝來直接利用上我們對 NN 定義的特殊層結構。封裝的過程則仍要用到元類:

# 定義作為封裝的元類class ConvSubLayerMeta(type): def __new__(mcs, *args, **kwargs): name, bases, attr = args[:3] conv_layer, sub_layer = bases def __init__(self, parent, shape, *_args, **_kwargs): conv_layer.__init__(self, None, parent=parent) # 與池化層類似、特殊層輸出數據的形狀應保持與輸入數據的形狀一致 self.out_h, self.out_w = parent.out_h, parent.out_w sub_layer.__init__(self, parent, shape, *_args, **_kwargs) self.shape = ((shape[0][0], self.out_h, self.out_w), shape[0]) # 如果是CNN中的Normalize、則要提前初始化好γ、β if name == "ConvNorm": self.tf_gamma = tf.Variable(tf.ones(self.n_filters), name="norm_scale") self.tf_beta = tf.Variable(tf.zeros(self.n_filters), name="norm_beta") # 利用NN中的特殊層結構的相應方法獲得結果 def _activate(self, x, predict): return sub_layer._activate(self, x, predict) def activate(self, x, w, bias=None, predict=False): return _activate(self, x, predict) # 將打包好的類返回 for key, value in locals().items(): if str(value).find("function") >= 0 or str(value).find("property"): attr[key] = value return type(name, bases, attr)# 定義CNN中的Dropout,注意繼承順序class ConvDrop(ConvLayer, Dropout, metaclass=ConvSubLayerMeta): pass# 定義CNN中的Normalize,注意繼承順序class ConvNorm(ConvLayer, Normalize, metaclass=ConvSubLayerMeta): pass

以上就是所有層結構的相關實現了……看到這裡的觀眾老爺們真的要給你們筆芯!至少我是看不下去的(喂

實例

感謝評論區@崔斯特的建議,我打算弄些栗子……不過雖然我非常努力地憋了三個栗子,但總感覺不太對勁……總之歡迎各種吐槽和各種意見 ( σω)σ

第一個栗子是普通層的栗子,假設我們的輸入矩陣為:

X = left[egin{matrix}0 & 1 & 2 & 1 & 0 \-1 & -2 & 0 & 2 & 1 \0 & 1 & -2 & -1 & 2 \1 & 2 & -1 & 0 & -2end{matrix}
ight]

亦即有 4 個樣本、每個樣本的維度是 5 維。然後我們的權值矩陣為:

w=left[egin{matrix}-2 & -1 & 0 & 1 & 2 \2 & 1 & 0 & -1 & -2end{matrix}
ight]^T

偏置量則簡單地取為b=1。現在我們要計算Xw+b的話,核心代碼只有兩行:

# Identical 為「無激活函數」的意思# 需要提供輸入維度( 5 )和輸出維度( 2 )nn_id = Identical([5, 2])# 調用相應函數進行計算# 其中 eval 是為了把數值從 Tensorflow 的 Graph 中提取出來print(nn_id.activate(nn_x, nn_w, nn_b).eval())

完整代碼如下:

with tf.Session().as_default() as sess: nn_x = np.array([ [ 0, 1, 2, 1, 0], [-1, -2, 0, 2, 1], [ 0, 1, -2, -1, 2], [ 1, 2, -1, 0, -2] ], dtype=np.float32) nn_w = np.array([ [-2, -1, 0, 1, 2], [ 2, 1, 0, -1, -2] ], dtype=np.float32).T nn_b = 1. nn_id = Identical([nn_x.shape[1], 2]) print(nn_id.activate(nn_x, nn_w, nn_b).eval())

上面這段代碼將會輸出:

要計算 Sigmoid 的話,只需要把 Identical 換成 Sigmoid 即可

第二、三個栗子是卷積的過程,我們統一假設輸入只有一個樣本、頻道也只有一個

第二個栗子是無 Padding 無 Stride 的情形,假設唯一的頻道(Channel)所對應的矩陣如下:

X=left[egin{matrix}0 & 2 & 1 & 2 \-1 & 0 & 0 & 1 \1 & 1 & 0 & 1 \-2 & 1 & -1 & 0end{matrix}
ight]

假設我們的卷積核(Kernel)有兩個 Channel:

f_1=left[egin{matrix}1 & 0 & 1 \-1 & 0 & 1 \1 & 0 & -1end{matrix}
ight],  f_2=left[ egin{matrix} 0 & 1 & 0 \1 & 0 & -1 \0 & -1 & 1end{matrix}
ight]

再假設我們的偏置量為b=left[ 1, -1
ight]。現在我們要計算相應的卷積時,核心代碼仍只有兩行:

# 接收的參數中,第一個是輸入的 shape,第二個是 Kernel 的 shape,具體而言:# 輸入的 shape 為 height x width x channel = 4 x 4 x 1# Kernel 的 shape 為 channel x height x width = 2 x 3 x 3conv_id = ConvIdentical([([4, 4, 1], [2, 3, 3])], padding="VALID")

可能有觀眾老爺看到這就想吐槽:為什麼輸入的 channel 放在最後,而 Kernel 的 channel 放在前面?其中的原因主要有兩點:

  • Tensorflow 默認 channel 在最後
  • 我在用 Numpy 實現框架時把 channel 放在了前面

然後……然後就是為了兼容、就變成這樣了(捂臉

不得不說把 channel 放在最後是非常合乎自然語言邏輯的:比如在描述圖片時,我們會自然地說它是N	imes M	imes 3的圖片,其中最後那個 3 就是 channel

那麼為什麼我用 Numpy 實現時把 channel 放在了前面呢?因為這樣的數組輸出時會更好看(捂臉)

就拿我們這第二個栗子來說吧,如果把 channel 放在最後:

conv_x = np.array([ [ [ 0, 2, 1, 2], [-1, 0, 0, 1], [ 1, 1, 0, 1], [-2, 1, -1, 0] ]], dtype=np.float32).reshape(1, 4, 4, 1)# 第一個 1 代表樣本數,最後那個 1 代表 channel 數

這樣的矩陣列印出來是這樣子的:

換句話說,同一個 channel 的東西會被放在同一列(很醜對不對!!);而如果我們把 channel 放前面:

conv_x = np.array([ [ [ 0, 2, 1, 2], [-1, 0, 0, 1], [ 1, 1, 0, 1], [-2, 1, -1, 0] ]], dtype=np.float32).reshape(1, 1, 4, 4)# 第一個 1 代表樣本數,第二個 1 代表 channel 數

這樣的矩陣列印出來是這樣子的:

好看多了對不對!!

總之大概就這麼個感覺……接下來看看第二個栗子的完整代碼:

with tf.Session().as_default() as sess: conv_x = np.array([ [ [ 0, 2, 1, 2], [-1, 0, 0, 1], [ 1, 1, 0, 1], [-2, 1, -1, 0] ] ], dtype=np.float32).reshape(1, 4, 4, 1) # 這裡有些兼容 Tensorflow 的 trick,大抵可以不必太在意…… conv_w = np.array([ [[ 1, 0, 1], [-1, 0, 1], [ 1, 0, -1]], [[0, 1, 0], [1, 0, -1], [0, -1, 1]] ], dtype=np.float32).transpose([1, 2, 0])[..., None, :] conv_b = np.array([1, -1], dtype=np.float32) conv_id = ConvIdentical([(conv_x.shape[1:], [2, 3, 3])], padding="VALID") print(conv_id.activate(conv_x, conv_w, conv_b).eval())

上面這段代碼將會輸出:

稍微解釋一下,比如說左上角的 4 是這樣求得的:

egin{matrix}0	imes 1 &+& 2	imes 0 &+& 1	imes 1 \-1	imes -1 &+& 0	imes 0 &+& 0	imes 1 \1	imes 1 &+& 1	imes 0 &+& 0	imes 1end{matrix}  + 1 = 4

右上角的 -1 是這樣求得的:

egin{matrix}0	imes 0 &+& 2	imes 1 &+& 1	imes 0 \-1	imes 1 &+& 0	imes 0 &+& 0	imes -1 \1	imes 0 &+& 1	imes -1 &+& 0	imes 1end{matrix}  - 1 = -1

這裡需要特別指出的是,Kernel 的第一個 channel 卷積出來的結果在第一列、第二個卷積出來的則在第二列

如果想計算帶 ReLU 的卷積的話,把上述 ConvIdentical 改成 ConvReLU 即可

第三個栗子是 Padding、Stride 均為 1 的情形,假設唯一的 Channel 所對應的矩陣如下:

X=left[egin{matrix}1 & 2 & 1 \-1 & 0 & -2 \1 & -1 & 2end{matrix}
ight]

加了 1 的 Padding 之後、輸入將變為:

X=left[egin{matrix}0 & 0 & 0 & 0 & 0 \0 & 1 & 2 & 1 & 0 \0 & -1 & 0 & -2 & 0 \0 & 1 & -1 & 2 & 0 \0 & 0 & 0 & 0 & 0 \end{matrix}
ight]

假設 Kernel、偏置量都不變,那麼在上述代碼的基礎上、只需如下的代碼即可完成第三個栗子所要求的卷積:

conv_x = np.array([ [ [ 1, 2, 1], [-1, 0, -2], [ 1, -1, 2] ]], dtype=np.float32).reshape(1, 3, 3, 1)conv_id = ConvIdentical([([3, 3, 1], [2, 3, 3])], padding=1, stride=2)print(conv_id.activate(conv_x, conv_w, conv_b).eval())

上面這段代碼將會輸出:

下一章會說明如何定義一個網路結構來封裝我們這章講的這些層結構,然後我們就能實際地跑跑 CNN 了 ( σω)σ

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


推薦閱讀:

為什麼大多數編程語言被設計成函數只有一個返回值,而不是多個?
基於 Flask 與 MySQL 實現番劇推薦系統
不同的語言中多進程和多線程具體的原理是什麼?
人人都愛數據科學家!Python數據科學精華實戰課程系列教程連載 ----長期更新中,敬請關注!

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