【python】卷積神經網絡:前向傳播與反向傳播的原理 & 僅使用numpy的CNN實現

一、概述

之前我們已經瞭解了普通的神經網絡——使用前向傳播和反向傳播來進行訓練。以MNIST數據集爲例,在該網址中已經進行了推導,並得到了超過96%的準確率。但是由於其自身的缺陷,想要更進一步提高準確率很困難。這是因爲雖然三層的神經網絡可以逼近任意的函數,但是我們的輸入不能表徵樣本的全部特徵——因此無法在“任意函數”中找到最好的,只能在“任意函數”中找到最適合輸入的。最適合輸入的不一定是最好的,那怎麼辦?

我們來看一下普通的神經網絡的輸入,還是以MNIST爲例,沒法子,用的數據集太少了,所以例子也顯得匱乏:普通神經網絡是將28*28的圖片變成784*1的一維向量,然後作爲輸入。現在問題來了:這個784*1的向量,其含有的信息量,和28*28的一樣大麼?當然不一樣大。784*1的向量損失了空間信息。這就類似把一篇文章進行進行分詞操作,得到的結果是一大堆詞語——失去了情節和邏輯。而這是很重要的信息。

這就是三層神經網絡的先天缺陷。如何在一定程度上彌補這種缺陷呢?保留“空間信息”咯,怎麼保存呢?這就輪到CNN登場了。

閱讀本文,你可以瞭解到:

常用的numpy函數的使用;

CNN的前向傳播原理及實現;

CNN的反向傳播原理及實現。

二、卷積、張量與池化

1、卷積

首先來想一想:什麼叫空間信息?對於一個28*28的矩陣,空間信息可以是我M[i,j]=1,它四周八個元素爲1,或者爲0,這種狀態可以稱之爲信息。如果把矩陣中的一個元素稱之爲一個像素,那麼3*3的矩陣可以保存一個像素周圍一小塊的空間信息。要是空間信息大一點呢?用4*4,5*5?好吧,就算我保存3*3,4*4,5*5......這只是一個像素周圍的二維空間信息,一共784個像素呢。我保存的怕不是個天文數字。退一萬步說,即使我保存了這些像素由小塊到大塊的空間信息,有足夠的空間去儲存,那下一個問題:參數有多少?對於一個28*28的圖片,用幾萬甚至幾十萬個參數去訓練,那可能家裏是挖礦的,算力溢出了。

現在可以看出來,如何表達空間信息,是一個大問題。CNN的核心,就是解決這個問題。怎麼解決的呢?用卷積。我特想吐槽卷積這個名字,本科的時候學那個信號處理,那個卷積我理解了好久。然後現在又遇到了卷積,有點PTSD。在學了之後,發現完全不是那麼回事。這裏面的卷積,說叫卷積,實際叫加權求和也沒什麼問題(儘管我知道離散卷積就是加權求和)。使用這種卷積操作就能夠保存一定量的空間信息。

卷積時,我們需要使用卷積核,這個卷積核,常見的形式爲n*n的矩陣。卷積操作,就是讓這個n*n的卷積核在m*m的圖片上一點一點滑動,每滑動一次,就讓m*m的圖片上的n*n區域的元素和卷積核上的元素對應相乘,得到一個新的n*n的矩陣,然後將這個新的n*n的矩陣中的所有值相加,作爲結果矩陣中的一個值。那麼結果矩陣多大?結果矩陣的大小爲(m-n+1)*(m-n+1)。

這個結果矩陣就保存着這張圖片所有區域上的某個圖像(空間)特徵。結果矩陣中的元素越大,說明卷積核劃過的對應區域該特徵越明顯,反之越不明顯。

現在我們可以保存一個特徵了,但是一個特徵哪裏夠用啊,我想要更多的特徵——那麼就需要更多的卷積核。每個卷積覈對應一個特徵,卷積核越多,記錄的空間特徵就越多,效果可能就越好。

2、張量

假設我們有32個卷積核,每個卷積核都在輸入的矩陣上游走一遍,那麼就能得到32個結果矩陣,通常我們稱結果矩陣爲特徵矩陣。也就是說,我們輸入一個矩陣,得到32個特徵矩陣。對這32個矩陣,我們有兩種選擇:第一種,32種特徵夠用了,我就把這個當做全連接的輸入吧;這樣當然可以。但是還有另外一種選擇:32種特徵不夠,我想要更多。

更多?那你去增加捲積核啊,32變64,變128,特徵就多了。可以這樣做,但是我們要注意,這裏增加捲積核,都是一個矩陣和一個矩陣做卷積,也就是說,採集的都是二維平面的信息,形象來說就是“報告,檢測到該區域有強烈的曲線反應!”“報告,檢測到該區域有微弱的直線反應!”“報告,檢測到該區域有強烈的三西格瑪形狀反應!”——別去糾結着三西格瑪是啥。我就想說一點,二維空間信息很有限,有限在哪裏呢?我只能得到一種特徵,因此輸入和卷積核都是二維的。想綜合得到兩種或以上的信息,比如說“報告,檢測到該區域有強烈的三角形反應,超強烈的毛茸茸反應,微弱的曲線反應!”那我可能就會想到這是一個小貓的耳朵。如何實現呢?

繼續卷積。之前不是已經得到了32個特徵矩陣麼?把這32個特徵矩陣合起來,當做一個大長方體,繼續卷積。這怎麼卷積啊?卷積核多大啊?

這樣卷積:假設我們第一次的32個卷積核都是5*5,那麼特徵矩陣大小爲(28-5+1)*(28-5+1)=24*24。32個特徵矩陣組成的大長方體大小就是32*24*24,那麼我的卷積核的高度也是32,長和寬可以再商量,但是高度一定是32。然後就讓第一層的24*24的矩陣和第一層的n*n的卷積核進行卷積,進行一次卷積後,一層都會得到一個值,一共得到32個值,把這32個值加起來,再加上bias(如果有的話),作爲新的特徵矩陣的一個元素。

這個元素包含了32種特徵的強弱,當然不能分辨出哪種更強——畢竟都加到一起了。但是已經夠用了。這下子,形象來說就是“報告,‘長度,曲線,三角形,毛茸茸’檢測器出現較強反應”,儘管我不能完全確定這是一個貓耳朵,因爲三角形特徵可能很弱,但是也比原來要好了。

現在我很貪心,我想還要32個卷積核,那麼特徵向量仍然是一個長方體。叫長方體太low了,不專業,我們連加權平均都改名叫卷積了,給長方體也取個牛逼的名字吧:張量。英文名?tensor。哦,狗家的TensorFlow就是張量流啊。張量的高,也有一個名字,就是通道。

現在我們知道了,第一層卷積,是二維張量和二維張量卷積,得到一個三維張量;第二層卷積,是三維張量和三維張量卷積,得到一個三維張量。之後還可以再卷積再卷積,都是三維張量。每次卷積後的張量,即卷積層的輸出張量和卷積前的張量,即輸入張量,各自有什麼變化呢?

通常來講,是越來越厚了。時刻記住一點:輸出張量的“高”,僅與本層的卷積核數量有關;本層卷積核的高,僅與與輸入張量的高有關。那麼,我們會發現張量在走過一層一層之後,越來越細,越來越長;越來越細,越來越長。

爲什麼會變長?第一個是因爲卷積核可能越來越多,第二個則是因爲,卷積核的高度可能和輸入張量的高不一致——我僅僅需要其中幾個特徵做卷積,那麼就會有更多的排列組合,得到的特徵矩陣就更多。

爲什麼會變細?正常來講,卷積之後肯定會變細的,因爲輸出矩陣的大小是(m-n+1)*(m-n+1)嘛。這種變細有的時候是好事,因爲減少了參數,有時候不是好事,因爲丟失了邊緣信息。如何防止張量變細呢?padding。什麼意思?就是在輸入的張量周圍包上一圈0,比如說我輸入的張量是一個28*28的矩陣,卷積核3*3,本來我的輸出矩陣是26*26,但我還想要28*28,怎麼辦?在28*28四周包上一圈0,變成30*30,那麼再卷積,得到的就是28*28了。

3、池化

經過第二層,假設第二層卷積有32個3*3卷積核,那麼得到的就是一個32*22*22的張量。得到這個張量好費勁啊,每個元素都是32*3*3的張量和32*24*24的張量卷積得到的。算起來太慢了。能不能減少數據啊,CPU要累死了,我心疼電費。

首先糾正一個錯誤,用CPU跑CNN不是一個好主意,用GPU更好,老黃家的卡有cuda,對張量計算有奇效。但是纔開始學CNN,可能沒那麼好的顯卡,所以暫時就用CPU吧。這麼慢那就肯定要減少數據量了。怎麼減少?池化。

池化是啥?池化就是選一個池子,用一個元素代表池子中的所有元素。如果是最大池化,那就是養蠱,選擇的元素是池子中最大的;如果是平均池化,那就是衆生平等,選擇池子中所有元素的平均值作爲代表元素。選出代表元素之後呢?用所有代表元素組成的新張量代替原來的張量啊。

不是,等會,車開的有點快。怎麼就代替了呢?這樣來看,我現在有一個32*24*24的張量,是第二層的輸入。現在我設置池化的池子爲2*2大,那麼對於每個24*24的矩陣,有12*12個池子,每個池子選一個代表元素,就把24*24減小到了12*12——原來的張量就從32*24*24變成了32*12*12,數據量縮小了足足四倍,太好用了。

爲什麼可以這麼選呢?按我的理解,張量儲存的也是空間信息,相鄰的張量儲存的空間信息,對應到原來的圖片上,是兩小片區域,而且離得很近,那麼從更廣的視野看,它們間的特徵也應該有聯繫,在大體上相差應該不大。那麼儲存四個,不如儲存一個。這就是池化了。

三、前向傳播和反向傳播

本文代碼主要來自該網址。目前純python,不用pytorch或者tf來寫CNN的是在太少了,找了好久才找到一份。然後看代碼看了一晚上才全看懂。從上述網址可以得到CNN的結構:卷積層→池化層→卷積層→卷積層→池化層→全連接層→softmax。

1、前向傳播

相比於反向傳播,前向傳播簡直人畜無害的簡單。前向傳播有三種情況:

輸入、池化→卷積,卷積→池化,池化→全連接。

①輸入、池化→卷積

CNN核心之一。主要有兩種情況,其一是二維張量和二維張量卷積,得到二維張量;其二是二維張量和三維張量卷積,得到二維張量。我們設輸入的張量爲X,卷積核爲\omega,大小爲n*n(三維張量爲h*n*n),輸出的張量爲Z。

則對於二維張量情況,

z_{ij}=\sum_{l=0}^{n}\sum_{k=0}^{n}a_{(i+l)(j+k)}\omega_{lk}+bias

三維張量情況,

z_{ij}=\sum_{m=0}^{h}\sum_{l=0}^{n}\sum_{k=0}^{n}a_{m(i+l)(j+k)}\omega_{mlk}+bias

②卷積→池化

本文所參考的使用maxpooling,即最大池化。從張量的每一層中劃分池子,分別取最大值即可。

③池化→全連接

也很簡單,先把池化層輸出的張量展開爲一維向量,然後輸入全連接層即可。

2、反向傳播

建議這部分配合該網址食用。

這裏我有必要重申一下反向傳播的特點。我們反向傳播,是爲了更新參數。參數更新的幅度怎麼算

\Delta \omega_{ij}=\eta\frac{\partial E}{\partial \omega_{ij}}=\eta\frac{\partial E}{\partial z_{j}}\frac{\partial z_j}{\partial \omega_{ij}}

這裏,\eta是學習率,我們事先指定,a_i是與\omega_{ij}相連的輸入值。關鍵就是後面那個偏導數。上面的E是loss function,也就是損失函數,因此,某個參數的更新量,就等於學習率*損失函數對該參數的偏導,也就等於學習率*損失函數對該層對應輸出的偏導*輸出對參數的偏導。

由於偏導的鏈式傳播的特點,無論l層和l-1層是卷積啊,池化啊,什麼的亂七八糟的,只要我們設z_{l+1}爲l+1層的輸出,z_{l}爲l層的輸出,那麼就有

\frac{\partial E}{\partial z_{l}}=\frac{\partial E}{\partial z_{l+1}}\frac{\partial z_{l+1}}{\partial z_{l}}

這意味着什麼呢?意味着我求任意一個參數的更新量,都可以通過求該層輸出的偏導,乘以該層輸出對該參數的偏導得到。這就很方便。由於這個偏導是在是太重要,因此就將誤差函數對某節點輸出值的偏導稱之爲該節點的“敏感度”,記爲\delta_l

總結起來,我想更新參數,就得知道參數的更新量,想知道參數的更新量,就得知道偏導,誤差函數對本層的輸出的偏導。然後,得知道上一層的偏導......輸出層的偏導。

因此,在反向傳播時,對第l層,有兩件事要做:

第一件,根據l層輸出的偏導,計算出l層參數的更新量

第二件,根據l層輸出的偏導,計算出l-1層輸出的偏導

也有三種情況:全連接→全連接,全連接→池化,池化→卷積,卷積→池化。

①全連接→全連接

太簡單了不說了。

②全連接→池化

同上。

③池化→卷積

得坐起來了,這個是反向傳播的核心之一。來看:假設我們的池化層爲32*12*12,卷積層爲32*24*24,我們怎麼得到卷積層的偏導?爲什麼不問池化層的參數更新?來來來,話筒給你,我問你池化層有什麼參數?池子大小?那是超參數,反向傳播改不了,得交叉驗證的時候纔可能改,也沒見改這個的。也就是說,問題就是怎麼更新池化層前一層的偏導。

怎麼更新呢?這個不是個數學問題,這是一種規定:

我們考察一下就可知,池化層前一層的偏導,個數比池化層要多,相當於一次反池化操作,由小擴大。由於池化又稱爲下采樣,那麼反池化就稱爲上採樣。怎麼採?根據池化方式的不同,有兩種手段:

對於maxpooling,記錄下maxpooling時候池子中的最大值,在上採樣的時候,一個元素自己展開爲一個池子,在這個池子中,maxpool時候的最大值的位置,此時是池化層輸出的偏導,其餘爲0。

對於averagepooling,同樣是一個元素a展開爲一個池子,池子中每個元素都爲a/(n*n),也就是把元素平均分到池子的每個元素中。

④卷積→池化

坐着也說不明白了,我站起來吧。這是整個CNN中最難的一步。來看:假設我們卷積層的輸出是一個52*10*10的張量,卷積核爲52*26*3*3的張量,輸入爲26*12*12的張量,怎麼求?

說實話,我看見這幾個張量我都暈了:這啥啊?這怎麼還有四維張量啊?怎麼卷積啊?看上面正向傳播那個單薄的式子也看不出啊。

那就先來看正向怎麼算的:

輸入爲26*12*12的張量,卷積核爲52*26*3*3的張量,別這麼看卷積核,這麼看難理解。你把卷積核看成一個個小盒子(三維張量),那麼就是52個高(通道數)爲26,長和寬都爲3的小盒子。然後每個小盒子和輸入的張量做一次卷積,得到一張大小爲10*10的紙,52個卷積核就做52次卷積,得到52張紙。這52張紙疊起來,組成一個52*10*10的張量。

這下清楚多了吧。

挺清楚的。那反過來怎麼做呢?

這可得慢慢說。我們挑出l層輸出的這個張量,52*10*10,它的每一張紙,10*10,對應一個卷積核和輸入張量的卷積。那麼卷積層的第一個任務:更新參數就很簡單了。我現在已經有了卷積層輸出的值,也就是這些張紙,我更新第h個卷積核,就需要找到第h張紙,讓它對第h個卷積核中的每個值求偏導即可。

那就求一下吧:

根據前向傳播,我們直接看第一層座標爲(1,1)這個地方的輸出:

z_{(1,1,1)}= a_{(1,1,1)}*\omega_{(1,1,1,1)}+a_{(1,1,2)}*\omega_{(1,1,1,2)}+a_{(1,1,3)}*\omega_{(1,1,1,3)}+ a_{(1,2,1)}*\omega_{(1,1,2,1)}+a_{(1,2,2)}*\omega_{(1,1,2,2)}+a_{(1,2,3)}*\omega_{(1,1,2,3)}+ a_{(1,3,1)}*\omega_{(1,1,3,1)}+a_{(1,3,2)}*\omega_{(1,1,3,2)}+a_{(1,3,3)}*\omega_{(1,1,3,3)}+...+a_{(26,1,1)}*\omega_{(1,26,1,1)}+a_{(26,1,2)}*\omega_{(1,26,1,2)}+a_{(26,1,3)}*\omega_{(1,26,1,3)}+ a_{(26,2,1)}*\omega_{(1,26,2,1)}+a_{(26,2,2)}*\omega_{(1,26,2,2)}+a_{(26,2,3)}*\omega_{(1,26,2,3)}+ a_{(26,3,1)}*\omega_{(1,26,3,1)}+a_{(26,3,2)}*\omega_{(1,26,3,2)}+a_{(26,3,3)}*\omega_{(1,26,3,3)}

差點給我寫吐了。

計算一個輸出值,就這麼麻煩。那麼讓E對第一個卷積核的(1,1,1)求偏導,即對\omega_{(1,1,1,1)}求偏導,就需要E對z的第一層的每個值求偏導,然後第一層的每個z對\omega_{(1,1,1,1)}求偏導。從上式可以得到 \frac{\partial z_{(1,1,1)}}{\partial \omega_{(1,1,1,1)}} ,即a_{(1,1,1)},但這不夠,我們還需要\frac{\partial z_{(1,1,2)}}{\partial \omega_{(1,1,1,1)}}......乃至\frac{\partial z_{(1,10,10)}}{\partial \omega_{(1,1,1,1)}}這些偏導乘上對應的“E對z”求偏導,纔是我們要的“E對第一個卷積核的(1,1,1)求偏導”

這麻煩炸了。這一個卷積核的偏導,要求10*10個偏導,能不能弄出一個直觀的形式啊?

試試吧。第一個是a_{(1,1,1)},第二個是a_{(1,1,2)},然後是a_{(1,1,3)}...a_{(1,1,10)};接下來a_{(1,2,1)}...a_{(1,2,10)};......;a_{(1,10,1)}...a_{(1,10,10)}。這是第一層。那麼還得乘上對應的“E對z”的偏導呢:

a_{(1,1,1)}\delta_{(1,1,1)}a_{(1,1,2)}\delta_{(1,1,2)}......a_{(1,10,1)}\delta_{(1,10,1)}...a_{(1,10,10)}\delta_{(1,10,10)}。這不就是輸入張量,那個52*12*12的第一張紙對輸出偏導張量,那個52*10*10,的第一張紙,求卷積的第一個值麼。

你要這麼說我可就不困了。那按這麼說,卷積核第二層的第一個值是兩個張量的第二張紙求卷積的第一個值......第二十六層也一樣。哦,那,“E對第一個卷積核的座標爲(h,i,j)處的參數求偏導”,等於輸入張量的第h層與偏導張量的第1層求卷積的(i,j)處的值

好起來了。那麼整體的第一個卷積核的偏導,它是一個張量,應該等於輸入張量每層分別與偏導張量求卷積!來看看尺寸對不對:

輸入張量一層是12*12,偏導張量一層是10*10,求卷積得到3*3,正好是卷積核一層的大小。沒錯了。

這樣我們就知道了卷積核的偏導該如何求。

接下來看卷積層的第二個任務:求上一層的偏導。簡而言之就是求\frac{\partial E}{\partial a_{(h,i,j)}},根據鏈式法則,我們要求\frac{\partial E}{\partial a_{(h,i,j)}},就要求出所有的z_{(H,I,J)},這個元素中含有a_{(h,i,j)}。又需要用到上面那個巨長無比的式子了:

我們現在想求\frac{\partial E}{\partial a_{(1,1,1)}},那麼,就得找到所有用到a_{(1,1,1)}的z。都有哪個z用到了a_{(1,1,1)}呢?回想一下前向傳播的過程:a的第一層乘以卷積核的第一層...a的第二十六層乘以卷積核的第二十六層,相加,得到一層輸出z。也就是說,每一層的z,都有a的第一層的參與。那究竟是每層z的哪一個值呢?z_{(h,1,1)},只有它在計算的時候用到了a_{(1,1,1)}。這有點難想,z每層的(1,1),是由a所有層的(1,1)...(3,3)和卷積核所有層的(1,1)...(3,3)卷積出來的,因此z_{(h,1,1)}用到了a_{(1,1,1)},它不但用到了a_{(1,1,1)},還有a_{(1,1,2)}......a_{(26,3,3)}。那麼我們就要求\frac{\partial z_{(h,1,1)}}{\partial a_{(1,1,1)}},也就是\frac{\partial z_{(1,1,1)}}{\partial a_{(1,1,1)}}......\frac{\partial z_{(52,1,1)}}{\partial a_{(1,1,1)}}

好麻煩啊!!!!!!

\frac{\partial z_{(1,1,1)}}{\partial a_{(1,1,1)}}=\omega_{(1,1,1,1)},...,\frac{\partial z_{(52,1,1)}}{\partial a_{(1,1,1)}}=\omega_{(52,1,1,1)}

然後也得乘\delta,\omega_{(1,1,1,1)}*\delta_{(1,1,1)},...,\omega_{(52,1,1,1)}*\delta_{(1,1,1)}。這些玩意再相加。

等一下,好像有點眼熟。這操作,好像是某種卷積的一部分?是偏導張量(52*10*10)的第一層(10*10)分別與每個卷積核的第一層(3*3)卷積,得到52個張量,它們全加起來,得到的就是我們要的“E對上一層偏導”的第一層麼?不對,應該在偏導張量外面包上0,否則尺寸不對了。來看一下,偏導張量是(10*10),卷積核是(3*3),那麼(10*10)與(3*3)卷積,得到的不可能是(12*12),要想得到(12*12),得在(10*10)外面包上兩圈0,變成(14*14)。但是在外面包上0,那\delta_{(1,1,1)}就沒法和\omega_{(1,1,1,1)}對應了啊,對應\delta_{(1,1,1)}的是\omega_{(1,1,3,3)}。這可怎麼辦。真令人頭大。

接着往下走一步吧。我們可以簡單想一下:z_{(h,1,1)}用到了a_{(1,1,1)},對於第h層,只有z_{(h,1,1)}用到了a_{(1,1,1)},這很好。但是a_{(1,1,2)}呢?z_{(h,1,1)}用到了a_{(1,1,2)}z_{(h,1,2)}也用到了a_{(1,1,2)},而且\frac{\partial z_{(1,1,2)}}{\partial a_{(1,1,2)}}=\omega_{(1,1,1,1)},也就是說,想找用到a_{(1,1,2)}的z,要比a_{(1,1,1)}多一倍:

\frac{\partial z_{(1,1,1)}}{\partial a_{(1,1,2)}}=\omega_{(1,1,1,2)}\frac{\partial z_{(1,1,2)}}{\partial a_{(1,1,2)}}=\omega_{(1,1,1,1)},......,\frac{\partial z_{(52,1,1)}}{\partial a_{(1,1,2)}}=\omega_{(52,1,1,2)}\frac{\partial z_{(52,1,2)}}{\partial a_{(1,1,2)}}=\omega_{(52,1,1,1)}

然後也得乘\delta\omega_{(1,1,1,2)}*\delta_{(1,1,1)}\omega_{(1,1,1,1)}*\delta_{(1,1,2)}...誒?又不對勁了啊。按我之前說的,偏導張量先padding,然後分別與每個卷積核卷積,那麼\frac{\partial E}{\partial a_{(1,1,2)}}應該是\omega_{(1,1,3,2)}*\delta_{(1,1,1)}\omega_{(1,1,3,3)}*\delta_{(1,1,2)},和我們上面推的完全對不上。

我們得相信我們的推導:

\omega_{(1,1,1,1)}*\delta_{(1,1,1)},這倆搭配求卷積是對的;

\omega_{(1,1,1,2)}*\delta_{(1,1,1)}\omega_{(1,1,1,1)}*\delta_{(1,1,2)},這搭配求卷積也是對的。

那麼問題來了。這個(14*14)與(3*3)卷積,怎麼能讓上面這個相乘呢?你第一個卷積,(14*14)就左下角有個值,難不成你\omega_{(1,1,1,1)}跑到左下角去了?誒?如果真是\omega_{(1,1,1,1)}跑到左下角,那第二個卷積中,\omega_{(1,1,1,1)}正好和\delta_{(1,1,2)}相乘啊,那\omega_{(1,1,1,2)}就轉到第三行的第二個去了?

也就是說,這個(3*3)它轉了180°,就是原來(1,1)變成(3,3),(1,2)變成(3,2),(1,3)變成(3,1)。然後再卷積。

成了,破案了。那麼整體對卷積層的上一層求偏導,應該是“上一層的第h層=偏導矩陣的第i層與第i個卷積核的第h層轉180°後求卷積,再對所有卷積後的結果求和”。

再用大小看一看:上一層的第h層是12*12,偏導矩陣的第i層是10*10,padding後是14*14,第i個卷積核的第h層是3*3,卷積後是12*12,偏導矩陣共52層,一共52個卷積核,都對應得上。沒問題了。

大功告成。

四、代碼實現

代碼來自該網址,本章主要對其代碼進行分析。

爲了使流程清晰,將CNN的各層分開爲卷積層、sigmoid層、ReLu層、池化層、全連接層、softmax層。使用CNN類總體掌控網絡結構和傳播流程。各層分別有對應的初始化方法、前向傳播方法和反向傳播方法。

1、卷積層

卷積層類是最核心也是最複雜的一個類了。

初始化方法:

class Conv_2D():
	#卷積層類
	def __init__(self, input_dim, output_dim, ksize=3,
				 stride=1, padding=(0,0), dilataion=None):
		self.input_dim = input_dim
		self.output_dim = output_dim
		self.ksize = ksize
		self.stride = stride
		self.padding = padding #(1,2) 左邊 和 上邊 填充一列 右邊和下邊填充2列
		self.dilatation = dilataion
		self.output_h = None
		self.output_w = None
		self.patial_w = None
		# 產生服從正態分佈的多維隨機隨機矩陣作爲初始卷積核
		# OCHW
		# self.conv_kernel = np.random.randn(self.output_dim, self.input_dim, self.kernelsize, self.kernelsize)  # O*I*k*k
		self.grad = np.zeros((self.output_dim, self.ksize, self.ksize, self.input_dim), dtype=np.float64)
		# 產生服從正態分佈的多維隨機隨機矩陣作爲初始卷積核
		self.input = None
		# OCh,w
		self.weights = np.random.normal(scale=0.1,size= (output_dim, input_dim, ksize, ksize))
		self.weights.dtype =np.float64
		self.bias = np.random.normal(scale=0.1,size = output_dim)
		self.bias.dtype = np.float64
		self.weights_grad = np.zeros(self.weights.shape)  # 回傳到權重的梯度
		self.bias_grad = np.zeros(self.bias.shape)  # 回傳到bias的梯度
		self.Jacobi = None  # 反傳到輸入的梯度

輸入參數中,input_dim爲輸入矩陣的維數;output_dim爲輸出矩陣的維數;ksize爲卷積核的大小,缺省則爲3;stride爲卷積核一次滑動的長度,缺省則爲1,;padding爲在左右和上下分別添加幾行元素;dilataion沒有用到。

該方法主要負責初始化類內的各個變量。主要關注以下幾個變量即可:self.weights,爲卷積核,是一個張量,維度爲(輸出維數,輸入維數,卷積核大小,卷積核大小);self.bias,偏移量。這兩組值均使用正態分佈進行初始化。self.weights_grad和self.bias_grad用來保存損失函數對卷積核元素和偏移量的偏導數。self.Jacobi用來保存上一層的偏導數。

前向傳播方法:

def forward(self, input):
		'''
		:param input: (N,C,H,W)
		:return:
		'''
		assert len(np.shape(input)) == 4
		input = np.pad(input, ((0, 0), (0, 0), (self.padding[0], self.padding[1]),
							   (self.padding[0], self.padding[1])), mode='constant', constant_values=0)
		#np.pad填充函數,在張量周圍填充數值。
		#第一個參數爲input,表述待填充的矩陣,之後會有n個整數對。
		#因爲這裏是四維張量,需要在最後兩個維度上添加0,因此n=4
		#第一、二個整數對(0,0),表示在兩個維度上不加。第三個(a,b)表示在紙張的上方添加a行,在紙張的下方添加b行;第四個整數對(m,n)表示在紙張的左邊添加m列,紙張的右邊添加n列
		#mode表示添加的行列中元素如何指定,這裏是添加常數,常數通過後面的參數constant_values指定,可以是一個參數,那麼就是全填充這一個
		#也可以是一個參數對(x,y),那麼就在上方和左邊填充x,在下方和右邊填充y
		self.input = input

		self.Jacobi = np.zeros(input.shape)
		N, C, H, W = input.shape#N爲紙張的數量,C爲紙張的厚度,H爲紙張的長度,W爲紙張的寬度


		# 輸出大小
		self.output_h = (H - self.ksize) / self.stride + 1#卷積結果,長度爲(紙張長度-卷積核長度)/移動步長+1,寬度同理
		self.output_w = (W - self.ksize ) / self.stride + 1

		# 檢查是否是整數
		assert self.output_h % 1 == 0
		assert self.output_w % 1 == 0
		self.output_h = int(self.output_h)
		self.output_w = int(self.output_w)

		imgcol = self.im2col(input, self.ksize, self.stride)  # (N*X,C*H*W)
		#該函數用於把張量轉爲二維矩陣,便於計算,input爲50*1*28*28的張量,而imgcol爲28800*25的矩陣,矩陣每一行爲卷積所需的元素
		#每張28*28的圖片要卷積24*24次,50張圖片要卷積50*24*24次,共28800次,因此有28800行

		output = np.dot(imgcol,
						self.weights.reshape(self.output_dim, -1).transpose(1, 0))  # (N*output_h*output_w,output_dim)
		#weight爲卷積核,共26個卷積核,是26*1*5*5的張量,也要轉換成矩陣,矩陣的每一列是一個卷積核,
		#這樣,imgcol的行乘上weights的列,就完成了一次卷積。有26個卷積核,因此做了26次卷積,得到的結果中,每個元素都是一次卷積運算的結果
		#每列是相同的卷積核的結果,每行是不同的卷積核的結果,結果爲28800*26,因此有26個卷積核得到結果

		output += self.bias
		output = output.reshape(N, self.output_w * self.output_h, self.output_dim). \
			transpose(0, 2, 1).reshape(N, int(self.output_dim), int(self.output_h), int(self.output_w))
		#矩陣轉換爲張量,結果是50*26*24*24,每張紙都變厚了26倍,說明經過了26個卷積核的卷積
		return output

函數的具體應用見註釋。

反向傳播方法:

	def backward(self, last_layer_delta,lr):
		'''
		計算傳遞到上一層的梯度
		計算到weights 和bias 的梯度 並更新參數
		:param last_layer_delta: 輸出層的梯度 (N,output_dim,output_h,output_w)
		:return:
		'''
		def judge_h(x):
			if x % 1 == 0 and x <= self.output_h-1 and x >= 0:
				return int(x)
			else:
				return -1
		def judge_w(x):
			if x % 1 == 0 and x <= self.output_w - 1 and x >= 0:
				return int(x)
			else:
				return -1
		# 根據推到出的公式找出索引與卷積權重相乘
		# mask用於得到每次卷積所需的敏感度矩陣
		for i in range(self.Jacobi.shape[2]):  # 遍歷輸入的高
			for j in range(self.Jacobi.shape[3]):  # W
				mask = np.zeros((self.input.shape[0], self.output_dim,
								 self.ksize, self.ksize))  # (N,O,k,k)
				index_h = [(i - k) / self.stride for k in range(self.ksize)]
				index_w = [(j - k) / self.stride for k in range(self.ksize)]
				index_h_ = list(map(judge_h, index_h))
				index_w_ = list(map(judge_w, index_w))

				for m in range(self.ksize):
					for n in range(self.ksize):
						if index_h_[m] != -1 and index_w_[n] != -1:
							mask[:, :, m, n] = last_layer_delta[:, :, index_h_[m], index_w_[n]]  # (N,O,1,1)
						else:
							continue
				#mask升維,由50*52*3*3變爲50*1*52*3*3
				mask = mask.reshape(self.input.shape[0], 1, self.output_dim, self.ksize, self.ksize)
				Jacobi_t=mask * self.weights.transpose(1, 0, 2, 3)
				Jacobi_s_t=np.sum(Jacobi_t, axis=(2, 3, 4))
				self.Jacobi[:, :, i, j] = Jacobi_s_t
		#去掉padding
		self.Jacobi = self.Jacobi[:, :, self.padding[0]:self.input.shape[2]-self.padding[1],
					  self.padding[0]:self.input.shape[3] - self.padding[1]]

		# 計算 w
		N,C,K,H,W = self.input.shape[0],self.input.shape[1],self.ksize**2,self.output_h,self.output_w
		tmp = np.zeros((N,C,K,H,W))
		for i in range(self.ksize):
			for j in range(self.ksize):
				#取出和對應位置相乘得數組
				tmp[:,:,i*self.ksize+j,:,:] = self.input[:, :,i:self.output_h + i:self.stride, j:self.output_w + j:self.stride]
	   # print(tmp.shape)
		tmp_new = np.sum(last_layer_delta.reshape(N,self.output_dim,1,1,H,W)*tmp.reshape(N,1,C,K,H,W),axis=(4,5)) #(N,O,C,K)
	   # print(tmp_new.shape)

		self.weights_grad = np.sum(tmp_new.reshape(N,self.output_dim,C,self.ksize,self.ksize).transpose(1,2,0,3,4),axis=2) #(O,C,ksize,ksize)
		# # 計算bias的梯度
		tmp_bias = np.sum(last_layer_delta, axis=(2, 3))
		self.bias_grad = np.sum(tmp_bias, axis=0)

		tmp_bias = np.sum(last_layer_delta, axis=(2, 3))
		self.bias_grad = np.sum(tmp_bias, axis=0)
		self.update(lr)
		return self.Jacobi

反向傳播中,最麻煩的就是如何確定公式中的張量了。我們沒有現成的方法,因此只能用矩陣對應元素相乘來做。在求上一層的偏導的時候,我們需要mask,敏感度矩陣,由於卷積變爲了對應元素相乘,我們需要的敏感度矩陣大小要和卷積核大小一樣,這樣執行相乘操作,然後將一層的所有值加在一起,就可以看成是一個卷積操作。說起來容易,如何在一層大的敏感度矩陣中找到我們需要的小的,這個好麻煩。

在找到適當的mask之後,我們來看一下mask和weight的尺寸。

mask爲50*52*3*3,這裏的第一個50,是mini batch GD帶來的,我們如果僅看一個樣本,那就是52*3*3。也就是要進行卷積的恰當大小的敏感度矩陣。weight爲52*26*3*3。有很重要的一步,對weight執行transpose(1, 0, 2, 3),維度變換,它的效果是什麼呢?是將weight變爲26*52*3*3,變成26個小箱子,每個小箱子高度是52,是由原來52個小箱子中的同一層得到的——那麼每個小箱子和mask相乘,得到一個52*3*3的新的張量,將這個張量所有元素相加,就是一個上一層的敏感度。一共有26個小箱子,那麼會得到26個數。然而直接相乘是不行的,爲了防止混淆,就需要將mask升維,給50和後面的52*3*3分開,在其中多加一個維度,作爲分隔符。注意一點:維度變換可以看成是張量沒變,你理解或者是觀察它的方式發生了變化。配合該網址食用。
這個多加一個維度,實際效果是什麼呢?有些難以理解。參見本節最後的張量計算技巧。

然後再累加得到26個數,填入前一層的敏感度即可。每經過一個這樣的流程,可以得到26個數,一共有26*12*12個數,因此每次是更新一束,一共要更新12*12束。

另外它這個實現方法很奇特,不是將weights旋轉180°,而是將mask旋轉180°。

然後需要確定卷積核如何更新:這次我們要從input中選取恰當的值,然後與輸出的敏感度相乘即可。注意這裏沒有需要調轉180°的了,因此實現起來比上面簡單了不少。最後更新即可。

2、池化層

池化層類是另一個核心類。

初始化方法:

class max_pooling_2D():
	def __init__(self,input_dim =3,stride = 2,ksize=2,padding=0):
		'''
		:param input_dim:
		:param stride:
		:param padding: padding數量
		'''
		self.input_dim = input_dim
		self.input = None
		self.output = None
		self.stride = stride
		self.ksize = ksize
		self.padding = padding
		self.record  = None #記錄取元素的位置

		self.Jacobi = None

沒什麼好說的,就是需要使用的變量。

前向傳播方法:

def forward(self,input):
		'''
		最大池化是找到2*2共四個元素中最大的作爲池化後的代表值
		:param input: (batchsize,c,h,w)
		:return:
		'''

		assert len(np.shape(input)) == 4

		self.record = np.zeros(input.shape)
		#padding
		input = np.pad(input, ((0, 0), (0, 0), (self.padding, self.padding),
							   (self.padding, self.padding)), mode='constant', constant_values=0)
		self.input = input
		 #
		input_N, input_C, input_h, input_w = input.shape[0], input.shape[1], \
												 input.shape[2], input.shape[3]
		# padding 操作
		#確定輸出的張量的尺寸,默認是2*2池化,張量尺寸n,c,h,w,則池化後第一第二維不變,紙張的尺寸變化
		#輸出爲50*26*12*12
		output_h = int((input_h - self.ksize + 2*self.padding) / self.stride + 1) #padding 操作
		output_w = int((input_w - self.ksize + 2 * self.padding) / self.stride + 1)

		output = np.zeros(((int(input_N),int(input_C),int(output_h),int(output_w))))

		for n in np.arange(input_N):
			for c in np.arange(input_C):
				for i in range(output_h):
					for j in range(output_w):
						#(batchsize,c,k,k)
						x_mask = input[n,c,i*self.stride:i*self.stride+self.ksize,
										   j*self.stride:j*self.stride+self.ksize]
						# print(x_mask)
						# print(np.max(x_mask))
						# print(output[n, c, i, j])
						output[n,c,i,j] = np.max(x_mask)

		self.output = output
		return  output

按部就班,先初始化輸出張量,計算它的尺寸。然後劃池子,找出最大值,放在輸出張量的對應位置。

反向傳播方法:

def backward(self,next_dz):
		'''

		:param next_dz: (N,C,H,W)
		:return:
		'''
		self.Jacobi = np.zeros(self.input.shape)
		N, C, H, W = self.input.shape
		_, _, out_h, out_w = next_dz.shape
		for i in range(out_h):
			for j in range(out_w):
				#print(self.input[:,:, i * self.stride:i * self.stride + self.ksize,j * self.stride:j * self.stride + self.ksize].shape)
				# print(input[n, c, i * self.stride:i * self.stride + self.ksize,j * self.stride:j * self.stride + self.ksize].shape)
				flat_idx = np.argmax(self.input[:,:,i*self.stride:i*self.stride+self.ksize,
								   j*self.stride:j*self.stride+self.ksize].reshape(N,C,self.ksize*self.ksize),axis=2)

				h_idx = (i*self.stride +flat_idx//self.ksize).reshape(-1) #(N*C) 確定行位置
				w_idx = (j*self.stride +flat_idx%self.ksize).reshape(-1) #確定列位置

				for k in range(N*C):
					self.Jacobi[k//C,k%C,h_idx[k],w_idx[k]] = next_dz[k//C,k%C,i,j] #對應回原來位置

				# self.Jacobifor k in range(N*C)
				# self.Jacobi[, c_list, h_idx.reshape(-1),w_idx.reshape(-1)] = next_dz[:,:,i,j]
		# 返回去掉padding的雅可比矩陣
		return self.Jacobi[:,:,self.padding:H-self.padding,self.padding:W-self.padding]

反向傳播,也就是上採樣,需要輸入的張量中池子中最大值的座標,這也是爲什麼我們要記錄用變量記錄輸入。流程是:初始化輸出張量,根據輸入張量的最大值位置,將敏感度張量中的值放到對應位置。

3、softmax層

class softmax():
	def __init__(self):
		self.output = None
		self.input_delta = None #記錄計算過程的雅可比矩陣
		self.Jacobi = None #反傳到輸入的雅可比矩陣
	def forward(self,input):
		'''
		:param input: (batchsize,n) np數組
		:return:
		'''

		batch_size = input.shape[0]
		#n = input.shape[1]
		self.Jacobi = np.zeros(input.shape)
		self.input_delta = np.zeros(input.shape)

		x = np.exp(input)
		y = np.sum(x,axis=1).reshape(batch_size,1)
		output = x/y
		self.output = output

		return output

	def backward(self,last_layer_delta):
		'''
		:param last_layer_delta: (N,n)
		:return:
		對softmax的輸入Zi求偏導,需要E對softmax的輸出a求偏導的向量和a對Zi求偏導的向量。前者爲last_layer_delta,要求後者
		當ai對Zi求偏導時,結果爲ai(1-ai),當ai對Zj求偏導時,結果爲-aiaj
		'''
		for n in range(last_layer_delta.shape[1]): #遍歷 n

			tmp = -(self.output*self.output[:,n].reshape(-1,1))
			#numpy中,[:,n]代表選取第n列,reshape(-1,1)表示將結果轉換爲1列
			#tmp中儲存-a0*an,-a1*an,-a2*an......-am*an
			tmp[:,n]+=self.output[:,n]
			#tmp中儲存a0-a0*an,-a1*an,-a2*an......-am*an
			#tmp現在就是後者
			self.Jacobi[:,n] = np.sum(last_layer_delta*tmp,axis=1)



		# batchsize = last_layer_delta.shape[0]
		# n = last_layer_delta.shape[1]

		return self.Jacobi

主要重點在於前向傳播的公式和反向傳播的公式,推導之後實現就很簡單了。

softmax求偏導的推導過程參見該網址

4、CrossEntropy層

class CrossEntropy():
	def __init__(self):
		self.loss = None
		self.Jacobi = None

	def forward(self,input,labels):
		bachsize = input.shape[0]
		loss = np.sum(-(labels * np.log(input) + (1 - labels) * np.log(1 - input)) / bachsize)
		self.loss = loss
		"""
		疑似有問題
		self.Jacobi = -(labels / input - input * (1 - labels) / (1 - input)) / bachsize
		"""
		self.Jacobi = -(labels / input - (1 - labels) / (1 - input)) / bachsize
		#因爲是批處理梯度下降,因此要除以bachsize
		return loss
	def backwards(self):
		return self.Jacobi

抓到原始代碼的一個小bug。對交叉熵求偏導的時候多乘了一個input。

注意我們使用mini batch進行梯度下降。batch取值50。那麼求偏導值時別忘了除以這個batch。我最開始有點暈:爲什麼要在這裏除以batch啊,你矩陣中一個元素不是對應一個樣本的偏差麼,不應該除。在這裏,如果我將矩陣中元素當做是樣本的偏差的話,在後面計算的時候,比如計算某個參數的偏導數,我會把50個樣本求得的偏導數都加起來,之後除以50作爲我們要的偏導數。也就是說,無論先除還是後除,早晚得除。在最開始除完之後,就不用在後面再除了,省了很多事。

5、網絡pipeline

我們在使用sklearn的時候,有很好用的pipeline,來幫助我們清晰的建立起整個網絡的流程。這裏沒有那麼好用的工具。因此需要我們自己一步一步設計網絡的各層。如下:

class CNN_Nets():
	def __init__(self,lr=0.0001,batchsize=10):
		'''
		初始化神經網絡,類似pipeline,卷積-池化-卷積-卷積-池化-全連接-softmax-輸出
		'''
		self.lr = lr
		self.bachsize = batchsize

		#第一個卷積層
		self.conv1 = Conv_2D(input_dim=1,output_dim=26,ksize = 5,stride = 1, padding =(0,0)) #(24,24)
		self.Relu_1 = Relu()
		self.maxpooling_1 = max_pooling_2D(input_dim=26,stride=2,ksize=2) #(12,12)
		self.conv2 = Conv_2D(input_dim=26,output_dim=52 ,ksize = 3, stride = 1, padding= (0,0)) #(10,10)
		self.Relu_2 = Relu()

		self.conv3 = Conv_2D(input_dim=52, output_dim=10, ksize=1, stride=1, padding=(0, 0))  # (10,10) #降維
		self.Relu_3 = Relu()
		self.maxpooling_3 = max_pooling_2D(input_dim=52,stride=2,ksize=2) #(5,5)

		self.fc_1 = Linear(input_num=5*5*10,output_num=1000)
		self.sigmoid_1 = sigmoid()
		self.fc_2 = Linear(input_num=1000,output_num=10)
		self.softmax = softmax()

		self.CrossEntropy = CrossEntropy()

		self.outut = None
		self.loss = None
		self.Jacobi = None

	def forward(self,input,labels):
		'''
		:param input: (n,c,h,w)
		:param labels: (batchsize,10) 的one_hot編碼
		:return:
		'''
		N,C,H,W = input.shape
		#50,1,28,28
		#卷積層1
		output = self.conv1.forward(input)
		#50,26,24,24
		output = self.Relu_1.forward(output)
		#50,26,24,24
		output = self.maxpooling_1.forward(input=output)
		#卷積層2
		#50,26,12,12
		output = self.conv2.forward(output)
		#50,52,10,10
		output = self.Relu_2.forward(output)
		#50,52,10,10
		output = self.conv3.forward(output)
		#50,10,10,10
		output = self.Relu_3.forward(output)
		#50,10,10,10
		output = self.maxpooling_3.forward(input=output)
		#卷積層3
		#50,10,5,5
		#第一個全連接層
		output = np.reshape(output,(N,10*5*5))
		#50,250
		output = self.fc_1.forward(output)
		#50,1000
		output = self.sigmoid_1.forward(output)
		#50,1000
		#第二個全連接層
		output = self.fc_2.forward(output)
		#50,10
		output = self.softmax.forward(output) #(batchsize,10)
		#50,10
		self.output = output
		#50,10
		#計算交叉熵和反傳梯度
		self.loss = self.CrossEntropy.forward(output,labels) #交叉熵

	def backward(self):

		 grad = self.CrossEntropy.Jacobi#50,10
		 #這一步的grad求出的是E對softmax中的偏導,可以看成是輸出層的誤差或輸出層的敏感度
		 grad = self.softmax.backward(grad)#50,10
		 grad = self.fc_2.backward(grad,lr =self.lr)#50,1000
		 grad = self.sigmoid_1.backward(grad)#50,1000
		 grad = self.fc_1.backward(grad,lr = self.lr)#50,250
		 grad = grad.reshape(self.bachsize,10,5,5) #重新恢復成圖像,50,10,5,5

		 grad = self.maxpooling_3.backward(grad)#50,10,10,10
		 grad = self.Relu_3.backward(grad)#50,10,10,10
		 grad = self.conv3.backward(grad,lr= self.lr)#50,52,10,10
		 grad = self.Relu_2.backward(grad)#50,52,10,10
		 grad = self.conv2.backward(grad,lr=self.lr)#50,26,12,12
		 grad = self.maxpooling_1.backward(grad)#50,26,24,24
		 grad = self.Relu_1.backward(grad)#50,26,24,24
		 grad = self.conv1.backward(grad,lr=self.lr)#50,1,28,28

		 return grad

這樣,網絡的前向傳播和反向傳播都可以很清晰的理解了,在調用的時候也很方便。注意到反向傳播中,下一個函數的輸入是上一個函數的輸出,完美的對應了反向傳播的特點。這對於理解網絡也很有好處。

在最開始讀代碼的時候,可能會感覺比較難懂,這時將各個函數的輸入張量和輸出張量寫下來並對照理解,對於理解函數的工作過程十分有幫助,不妨一試。

6、張量計算技巧

讓我們以下面的代碼作爲示範:

import numpy as np
array1 = np.array([[[1, 1],[2,2]],[[3,3],[4,4]]])
array2 = np.array([[[1, 1],[2,2]],[[3,3],[4,4]]])
#array1=array1.reshape(2,1,2,2)
array3=array1*array2
print(array3)
print(array3.shape)
array1=array1.reshape(2,1,2,2)
array3=array1*array2
print(array3)
print(array3.shape)

最開始,array1和array2是兩個標準的三維張量,2*2*2,看作是兩個立方體。相乘之後得到一個立方體——大小不變,也就是對應層相乘,結果爲:

現在我把array1升維,變成2*1*2*2,由一個立方體變成兩張紙,那麼相乘之後得到兩個立方體,結果是一個四維張量,2*2*2*2。第一個立方體是array1的第一張紙分別與array2的兩層分別相乘,第二個立方體第二張紙,結果如下:

也就是說,加上這額外的一維之後,相當於由各層對應相乘,變爲了一層對應多層。那如果不是一張紙呢?比如說我是四維升一維,那結果怎麼看?看下面的代碼:

import numpy as np
array1 = np.array([[[[1, 1],[2,2]],[[3,3],[4,4]],[[5,5],[6,6]],[[7,7],[8,8]]],[[[9, 9],[10,10]],[[11,11],[12,12]],[[13,13],[14,14]],[[15,15],[16,16]]]])
array2=array1
print(array1.shape)
print(array1)
array3=array1*array2
print(array3.shape)
print(array3)
array1=array1.reshape(2,1,4,2,2)
array3=array1*array2
print(array3.shape)
print(array3)

可以看出,array1和array2都是2*4*2*2的張量。那麼,兩個這樣的張量相乘,結果爲:

可以看出,還是對應層相乘。然後給array1升維:變成2*1*4*2*2,結果有點複雜:

    

結果變成2*2*4*2*2,這是怎麼計算出來的?觀察可知,新增維度後面的是4*2*2的張量,我們可以這麼看:結果就是新增維度後面的張量,看做一塊磚,而另外一個,沒有升維的array2,自身有兩塊磚。array1的一塊磚分別和array2的磚相乘,得到結果,結果和array2一樣大。那array1有幾塊磚呢?兩塊。那麼結果就是兩個array那麼大的張量,組合起來大小就是2*2*4*2*2。

五、預測結果

我們的mini batch大小爲50,每次訓練一個minibatch作爲一次迭代,每次迭代計算一次損失函數,每20次迭代驗證一次準確率。這時驗證準確率選擇的測試集大小爲500。如下圖所示,爲11800次迭代,也就是遍歷十次訓練集後的損失函數曲線和準確率曲線。

可以很清晰的看出來,在約5000次迭代之後,迭代的效果已經很不明顯了,loss曲線(左)出現了一條寬寬的尾巴,難以變窄,說明通過迭代使loss值變小的效率已經十分低,loss波動較明顯,不夠穩定。

準確率曲線(右)也一樣,將橫軸乘以20就是迭代次數。可以看出,在迭代200*20約4000次以後,準確率曲線幾乎與x軸平行,也有波動,但波動不是很大。實際上在最後,一直在98%到99%波動。

每經過1180次迭代,將全部訓練集遍歷一遍,再進行一次驗證。這時選擇全部測試集進行驗證。如下圖:

如圖,第一次遍歷全部訓練集,對全部測試集的準確率就已經有96.5%了,隨後準確率穩步上升,在十次全部遍歷之後準確率上升到98.5%。並且上升斜率越來越慢。

六、總結

CNN的複雜程度相比於之前寫的神經網絡上升了至少一個臺階。正向傳播略微簡單一點,但反向傳播十分複雜,而且在引入高維張量以後更加複雜。主要難點就在於卷積和池化的逆操作。由於初識張量,因此對其的操作和性質幾乎一無所知。想要真正理解和記憶正反向傳播的過程和原理,需要自己着實下一番功夫,不能嫌棄過於複雜,或者犯懶而不去手動計算,那樣只能是一知半解。

另外,CNN的訓練極其緩慢,我的電腦使用CPU進行訓練,遍歷十次訓練集花費超過12小時,十分煎熬。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章