一、概述
之前我們已經瞭解了普通的神經網絡——使用前向傳播和反向傳播來進行訓練。以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,卷積核爲,大小爲n*n(三維張量爲h*n*n),輸出的張量爲Z。
則對於二維張量情況,
,
三維張量情況,
。
②卷積→池化
本文所參考的使用maxpooling,即最大池化。從張量的每一層中劃分池子,分別取最大值即可。
③池化→全連接
也很簡單,先把池化層輸出的張量展開爲一維向量,然後輸入全連接層即可。
2、反向傳播
建議這部分配合該網址食用。
這裏我有必要重申一下反向傳播的特點。我們反向傳播,是爲了更新參數。參數更新的幅度怎麼算?
這裏,是學習率,我們事先指定,是與相連的輸入值。關鍵就是後面那個偏導數。上面的E是loss function,也就是損失函數,因此,某個參數的更新量,就等於學習率*損失函數對該參數的偏導,也就等於學習率*損失函數對該層對應輸出的偏導*輸出對參數的偏導。
由於偏導的鏈式傳播的特點,無論l層和l-1層是卷積啊,池化啊,什麼的亂七八糟的,只要我們設爲l+1層的輸出,爲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)這個地方的輸出:
差點給我寫吐了。
計算一個輸出值,就這麼麻煩。那麼讓E對第一個卷積核的(1,1,1)求偏導,即對求偏導,就需要E對z的第一層的每個值求偏導,然後第一層的每個z對求偏導。從上式可以得到 ,即,但這不夠,我們還需要......乃至。這些偏導乘上對應的“E對z”求偏導,纔是我們要的“E對第一個卷積核的(1,1,1)求偏導”。
這麻煩炸了。這一個卷積核的偏導,要求10*10個偏導,能不能弄出一個直觀的形式啊?
試試吧。第一個是,第二個是,然後是...;接下來...;......;...。這是第一層。那麼還得乘上對應的“E對z”的偏導呢:
,.........。這不就是輸入張量,那個52*12*12的第一張紙對輸出偏導張量,那個52*10*10,的第一張紙,求卷積的第一個值麼。
你要這麼說我可就不困了。那按這麼說,卷積核第二層的第一個值是兩個張量的第二張紙求卷積的第一個值......第二十六層也一樣。哦,那,“E對第一個卷積核的座標爲(h,i,j)處的參數求偏導”,等於輸入張量的第h層與偏導張量的第1層求卷積的(i,j)處的值。
好起來了。那麼整體的第一個卷積核的偏導,它是一個張量,應該等於輸入張量每層分別與偏導張量求卷積!來看看尺寸對不對:
輸入張量一層是12*12,偏導張量一層是10*10,求卷積得到3*3,正好是卷積核一層的大小。沒錯了。
這樣我們就知道了卷積核的偏導該如何求。
接下來看卷積層的第二個任務:求上一層的偏導。簡而言之就是求,根據鏈式法則,我們要求,就要求出所有的,這個元素中含有。又需要用到上面那個巨長無比的式子了:
我們現在想求,那麼,就得找到所有用到的z。都有哪個z用到了呢?回想一下前向傳播的過程:a的第一層乘以卷積核的第一層...a的第二十六層乘以卷積核的第二十六層,相加,得到一層輸出z。也就是說,每一層的z,都有a的第一層的參與。那究竟是每層z的哪一個值呢?,只有它在計算的時候用到了。這有點難想,z每層的(1,1),是由a所有層的(1,1)...(3,3)和卷積核所有層的(1,1)...(3,3)卷積出來的,因此用到了,它不但用到了,還有......。那麼我們就要求,也就是......。
好麻煩啊!!!!!!
,...,。
然後也得乘,,...,。這些玩意再相加。
等一下,好像有點眼熟。這操作,好像是某種卷積的一部分?是偏導張量(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,那就沒法和對應了啊,對應的是。這可怎麼辦。真令人頭大。
接着往下走一步吧。我們可以簡單想一下:用到了,對於第h層,只有用到了,這很好。但是呢?用到了,也用到了,而且,也就是說,想找用到的z,要比多一倍:
,,......,,。
然後也得乘,,...誒?又不對勁了啊。按我之前說的,偏導張量先padding,然後分別與每個卷積核卷積,那麼應該是,,和我們上面推的完全對不上。
我們得相信我們的推導:
,這倆搭配求卷積是對的;
,,這搭配求卷積也是對的。
那麼問題來了。這個(14*14)與(3*3)卷積,怎麼能讓上面這個相乘呢?你第一個卷積,(14*14)就左下角有個值,難不成你跑到左下角去了?誒?如果真是跑到左下角,那第二個卷積中,正好和相乘啊,那就轉到第三行的第二個去了?
也就是說,這個(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小時,十分煎熬。