1. 程式人生 > >小白的經典CNN復現(二):LeNet-5

小白的經典CNN復現(二):LeNet-5

# 小白的經典CNN復現(二):LeNet-5 各位看官大人久等啦!我胡漢三又回來辣(不是 最近因為到期末考試周,再加上老闆臨時給安排了個任務,其實LeNet-5的復現工作早都搞定了,結果沒時間寫這個部落格,今天總算是抽出時間來把之前的工作簡單總結了一下,然後把這個文章簡單寫了一下。 因為LeNet-5這篇文章實在是太——長——了,再加上內容稍稍有那麼一點點複雜,所以我打算大致把這篇部落格分成下面的部分: * 論文怎麼讀:因為太多,所以論文裡面有些部分可以選擇性略過 * 論文要點簡析:簡單說一下這篇文章中提出了哪些比較有意思的東西,然後提一下這個論文裡面有哪些坑 * 具體分析與復現:每一個部分是怎麼回事,應該怎麼寫程式碼 * 結果簡要說明:對於復現的結果做一個簡單的描述 * 反思:雖然模型很經典,但是實際上還是有很多的考慮不周的地方,這些也是後面成熟的模型進行改進的地方 在看這篇部落格之前,希望大家能先滿足下面的兩個前置條件: * 對卷積神經網路的大致結構和功能有一定的瞭解,不是完完全全的小白 * 對Pytorch有初步的使用經驗,不需要特別會,但起碼應該知道大致有什麼功能 * LeNet-5這篇論文要有,可以先不讀,等到下面講完怎麼讀之後再讀也沒問題 那麼廢話少說,開始我們的復現之旅吧(@\^▽\^@)ノ 順便一提,因為最近老是在寫報告、論文還有文獻綜述啥的,文章風格有點改不回來了,所以要是感覺讀著不如以前有意思了,那······湊合讀唄,你還能打死我咋的┓( ´∀` )┏ ## 論文該怎麼讀? 這篇論文的篇幅。。。講道理當時我看到頁碼的時候我整個人是拒絕的······然後瞅了一眼introduction部分,發現實際上裡面除了介紹他的LeNet-5模型之外,還介紹瞭如何構建一個完整的文字識別的系統,順便分析了一下優劣勢什麼的。也就是說這篇論文裡面起碼是把兩三篇論文放在一起發的,趕明兒我也試試這麼水論文┓( ´∀` )┏ 因此這篇論文算上參考文獻一共45頁,可以說對於相關領域的論文來說已經是一篇大部頭的文章了。當然實際上關於文字識別系統方面的內容我們可以跳過,因為近年來對於文字識別方面的研究其實比這個裡面提到的無論是從精度還是系統整體效能上講都好了不少。 那這篇論文首先關於導讀部分還有文字識別的基本介紹部分肯定是要讀的,然後關於LeNet-5的具體結構是什麼樣的肯定也是要讀的,最後就是關於他LeNet-5在訓練的時候用到的一些“刀劍神域”操作(我怕系統不讓我說SAO這個字),是在文章最後的附錄裡面講的,所以也是要看的。把這些整合一下,對應的頁碼差不多是下面的樣子啦: * 1-5:文字識別以及梯度下降簡介 * 5-9:LeNet-5結構介紹 * 9-11:資料集以及訓練結果分析 * 40-45:附錄以及參考文獻 基本上上面的這些內容看完,這篇文章裡面關於LeNet-5的內容就能全都看完了,其他的地方如果感興趣的話自己去看啦,我就不管了哈(滑稽.jpg 在看下面的內容之前,我建議先把上面我說到的那些頁碼裡面的內容先大致瀏覽一下,要不然下面我寫的東西你可能不太清楚我在說什麼,所以大家加把勁,先把論文讀一下唄。(原來我就是加把勁騎士(大霧) ## 論文要點簡析 這篇論文的東西肯定算是特別特別早了,畢竟1998年的老古董嘛(那我豈不是更老······話說我好像暴露年齡了欸······)。實際上這裡面有一些思想已經比較超前了,雖然受當時的理論以及程式設計思路的限制導致實現得並不好,但是從思路方面上我覺得絕對是有學習的價值的,所以下面我們就將這些內容簡單來說一說唄: * 首先是關於全連線網路為啥不好。在文章中主要提到下面的兩個問題: * 全連線網路並沒有平移不變性和旋轉不變性。平移不變性和旋轉不變性,通俗來講就是說,如果給你一張圖上面有一個東西要識別,對於一個具有平移不變性和旋轉不變性的系統來說,不管這張圖上的這個東西如何做平移和旋轉變換,系統都能把這個東西辨識出來。具體為什麼全連線網路不存在平移不變性和旋轉不變性,可以參考一下我之前一直在推薦的《Deep Learning with Pytorch》這本書,裡面講的也算是清晰易懂吧,這裡就不展開說了; * 全連線網路由於要把圖片展開變成一個行/列向量進行處理,這會導致圖片畫素之間原有的拓撲結構遭到破壞,畢竟對於圖片來講,一個畫素和他周圍的畫素之間的關係肯定是很密切的嘛,要是不密切插值不就做不了了麼┓( ´∀` )┏ * 在卷積神經網路結構方面,也提出了下面的有意思的東西: * 池化層:前面提到過全連線網路不存在平移不變性,而從原理上講,卷積層是平移不變的。為了讓整個辨識系統的平移不變性更加健壯,可以引入池化層將識別出的特徵的具體位置再一次模糊化,從而達到系統的健壯性的目的。嘛······這個想法我覺的挺好而且挺超前的,然而,LeCun大佬在這裡的池化用的是平均池化······至於這有什麼問題,emmmmm,等到後面的反思裡面再說吧,這裡先和大家提個醒,如果有時間的話可以停下來先想一想為啥平均池化為啥不好。 * 特殊設計的卷積層:在整個網路中間存在一個賊噁心的層,對你沒看錯,就是賊噁心。當然啦,這個噁心是指的復現層面的,從思路上講還是有一些學習意義的。這個卷積層不像其他的卷積層,使用前面一層輸出的所有的特徵圖來進行卷積,他是挑著來的,這和我的上一篇的LeNet-1989提到的那個差不多。這一層的設計思想在於:1)控制引數數量防止過擬合(這其實就有點像是完全確定的dropout,而真正的dropout是在好幾年以後才提出的,是不是很超前吖);2)破壞對稱性;3)強制讓卷積核學習到不同的特徵。從第一條來看,如果做到隨機的話那和dropout就差不多了;第二條的話我沒太看明白,如果有大佬能夠指點一下的話那就太好了;第三條實際上就是體現了想要儘可能減少冗餘卷積核從而減少引數數量的思想,相當於指明瞭超引數的一個設定思路。 * RBF層與損失函式:通過向量距離來表徵損失,仔細分析公式的話,你會發現,他使用的這個 層加上設計的損失函式,和我們現在在分類問題中常用的交叉熵函式(CrossEntropyLoss)其實已經非常接近了,在此之前大家使用的都是那種one-hot或者基於位置編碼的損失函式,從原理性上講已經是一個很大的進步了······雖然RBF本身因為計算向量距離的緣故,實際上把之前的平移不變性給破壞了······不過起碼從思路上講已經好很多了。 * 特殊的啟用函式:這個在前一篇LeNet-1989已經提到過了,這裡就不展開說了,有興趣可以看一下論文的附錄部分還有我的上一篇關於LeNet-1989的介紹。 * 初始化方法:這個也在之前一篇的LeNet-1989提到過了,大家就到之前的那一篇瞅瞅(順便給我增加點閱讀量,滑稽.jpg 以上就是論文裡面一些比較有意思並且有價值的思想和內容,當然了這裡只是針對那些剛剛簡單看過一遍論文的小夥伴們看的,是想讓大家看完論文以後對一些可能一晃就溜過去的內容做個提醒,所以講得也很簡單。如果上面的內容確實是有沒注意到的,那就再回去把這些內容找到看一看;如果上面的內容都注意到了,哇那小夥伴你真的是棒!接下來就跟著我繼續往下看,把一些很重要的地方進行一些更細緻的研讀吧(⁎˃ᴗ˂⁎) ## 具體分析與復現 現在我們假設大家已經把論文好好地看過一遍了,但是對於像我一樣的新手小白來說,有一些內容可能看起來很簡單,但是實際操作起來完全不知道該怎麼搞,所以這裡就和大家一起來一點一點扣吧。 首先先介紹一下我復現的時候使用的大致軟體和硬體好了:python: 3.6.x,pytorch: 1.4.1, GPU: 1080Ti,window10和Ubuntu都能執行,只需要把檔案路徑改成對應作業系統的格式就行 在開始寫程式碼之前,同樣的,把我們需要的模組啥的,一股腦先都裝進來,免得後面有什麼東西給忘記了: ```python import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets from torchvision import transforms as T import matplotlib.pyplot as plt import numpy as np ``` 接下來我們開始介紹復現過程,論文的描述是先說的網路結構,然後再講的資料集,但是其實從邏輯上講,我們先搞清楚資料是什麼個鬼樣子,才知道網路應該怎麼設計嘛。所以接下來我們先介紹資料集的處理,再介紹網路結構。 ### 資料集的處理部分 這裡使用的就是非常經典的MNIST資料集啦,這個資料集就很好找了,畢竟到處都是,而且也不是很大,拿來練手是再合適不過的了(柿子肯定是挑軟的捏,飯肯定專挑軟的吃,滑稽.jpg)。一般來說為了讓訓練效果更好,都需要對資料進行一些預處理,使資料的分佈在一個合適的範圍內,讓訓練過程更加高效準確。 在介紹怎麼處理資料之前,還是先簡單介紹一下這個資料集的特點吧。MNIST資料集中的圖片的尺寸為[28, 28],並且都是單通道的灰度圖,也就是裡面的字都是黑白的。灰度圖的畫素範圍為[0, 255],並且全都是整數。 由於在這個網路結構中使用的啟用函式都是和Tanh或者Sigmoid函式十分接近的,為了能讓訓練過程總體上都在啟用函式的線性區中,需要將資料的畫素數值分佈從之前的[0, 255]轉換成均值為0,方差為1的一個近似區間。為了達到這個效果,論文提出可以把圖片的畫素值範圍轉換為[-0.1, 1.175],也就是說背景的畫素值均為-0.1,有字的最亮的畫素值為1.175,這樣所有圖片的畫素值就近似在均值為0,方差為1的範圍內了。 除此之外,論文還提到為了讓之後的最後一層的感受野能夠感受到整個數字,需要將這個圖片用背景顏色進行“填充”。注意這裡就有兩個需要注意的地方: * 填充:也就是說我們不能簡單地用PIL庫或者是opencv庫中的resize函式,因為這是將圖片的各部分進行等比例的插值縮放,而填充的實際含義和卷積層的padding十分接近,因此為了方便起見我們就直接在卷積操作中用padding就好了,能省事就省點事。 * 用背景填充:在卷積進行padding的時候,預設是使用0進行填充,而這和我們的實際的要求是不一樣的,因此我們需要對卷積的padding模式進行調整,這個等到到時候講卷積層的時候再詳細說好了。 因此考慮到上面的因素,我們的圖片處理器應該長下面的這個鬼樣子: ```python picProcessor = T.Compose([ T.ToTensor(), T.Normalize( mean = [0.1 / 1.275], std = [1.0 / 1.275] ), ]) ``` 具體裡面的引數都是什麼意思,我已經在以前的部落格裡面提到過了,所以這裡就不贅述了哦。圖片經過這個處理之後,就變成了尺寸為[28, 28],畫素值範圍[-0.1, 1.175]的tensor了,然後如何填充成一個[32, 32]的圖片,到後面的卷積層的部分再和大家慢慢說。 資料處理完,就載入一下吧,這裡和之前的LeNet-1989的程式碼基本上就一樣的吖,就不多解釋了。 ```python dataPath = "F:\\Code_Set\\Python\\PaperExp\\DataSetForPaper\\" #在使用的時候請改成自己實際的MNIST資料集路徑 mnistTrain = datasets.MNIST(dataPath, train = True, download = False, transform = picProcessor) #記得如果第一次用的話把download引數改成True mnistTest = datasets.MNIST(dataPath, train = False, download = False, transform = picProcessor) ``` 同樣的,如果有條件的話,大家還是在GPU上訓練吧,因為這個網路結構涉及到一些比較複雜的中間運算,如果用CPU訓練的話那是真的慢,反正我在我的i7-7700上面訓練,完整訓練下來大概一天多?用GPU就幾個小時,所以如果實在沒條件的話,就跟著我把程式碼敲一遍,看懂啥意思就行了,這個我真的沒辦法┓( ´∀` )┏ ```python device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") ``` ### 網路結構部分 LeNet-5的結構其實還是蠻經典的,不過在這裡還是再為大家截一下圖,然後慢慢解釋解釋每層是怎麼回事吧。 ![](https://img2020.cnblogs.com/blog/2248060/202101/2248060-20210121154005171-1478816554.jpg) 因為這裡面的東西其實蠻多的,我怕像上一篇一樣在最後才把程式碼一下子放出來會讓人記不住前面講過啥,所以這部分就每一個結構下面直接跟上對應的程式碼好了。 #### 整體 我們的神經網路類的名字就定義為LeNet-5好了,大致定義方法如下: ```python class LeNet-5(nn.Module): def __init__(self): super(LeNet-5, self).__init__() self.C1 = ... self.S2 = ... self.C3 = ... self.S4 = ... self.C5 = ... self.F6 = ... self.Output = ... self.act = ... 初始化部分... def forward(self, x): ...... ``` 那接下來我們就一個部分一個部分開始看吧。 #### C1層 C1層就是一個很簡單的我們平常最常見的卷積層,然後我們分析一下這一層需要的引數以及輸入輸出的尺寸。 * 輸入尺寸:在不考慮batch_size的情況下,論文中提到的輸入圖片的尺寸應該是[c, h, w] = [1, 32, 32],但是前面提到,我們為了不在圖片處理中花費太大功夫進行圖片的填充,需要把圖片的填充工作放在卷積操作的padding中。從尺寸去計算的話,padding的維度應該是2,這樣就能把實際圖片的高寬尺寸從[28, 28]填充為[32, 32]。但是padding的預設引數是插入0,並不是用背景值 -0.1 進行填充,所以我們需要在定義卷積核的時候,將padding_mode這個引數設定為 ’replicate‘,這個引數的意思是,進行padding的時候,會把周圍的背景值進行復制賦給padding的維度。 * 輸出尺寸:在不考慮batch_size的情況下,輸出的特徵圖的尺寸應該是[c, h, w] = [6, 28, 28] * 引數:從輸入尺寸還有輸出尺寸並結合論文的描述上看,使用的卷積引數應該如下: * in_channel: 1 * out_channel: 6 * kernel_size: 5 * stride: 1 * padding: 2 * padding_mode: 'replicate' 將上面的內容整合起來的話,C1層的構造程式碼應該是下面的樣子: ```python self.C1 = nn.Conv2d(1, 6, 5, padding = 2, padding_mode = 'replicate') ``` 在這一層的後面沒有啟用函式喲,至少論文裡沒有提到。 #### S2層 S2層以及之後所有的以S開頭的層全都是論文裡面提到的取樣層,也就是我們現在常說的池化層。論文中提到使用的池化層是平均池化,池化層的概念和運作原理,大家還是去查一下其他的資料看一看吧,要不然這一篇篇幅就太長了······但是需要注意的是,這裡使用的平均池化和實際我們現在常見的平均池化是不一樣的。常見的池化層是,直接將對應的位置的值求個平均值,但是這裡很噁心,這裡是有權重和偏置的平均求和,差不多就是下面這個樣子: $$ y=w(a_1+a_2+a_3+a_4)+b $$ 這倆引數w和b還是可訓練引數,每一個特徵圖用的還不是同一個引數,真的我看到這裡是拒絕的,明明CNN裡面平均池化就不適合用,他還把平均池化搞得這麼複雜,吔屎啦你(╯‵□′)╯︵┻━┻ 但是自己作的死,跪著也要作完,所以大家就一起跟著我吔屎吧······ 在Pytorch裡,除了可以使用框架提供的API裡面的池化層之外,我們也可以去自定義一個類來實現我們自己需要的功能。當然如果想要這個自定義的類能夠和框架提供的類一樣執行的話,需要讓這個類繼承torch.nn.Module這個類,只有這樣我們的自定義類才有運算、自動求導等正常功能。並且相關的功能的實現,需要我們自己重寫forward方法,這樣在呼叫自己寫的類的物件的時候,系統就會通過內建的\_\_call\_\_方法來呼叫這個forward方法,從而實現我們想要的功能。 下面我們構建一個類Subsampling來實現我們的池化層: ```python class Subsampling(nn.Module) ``` 首先看一下我們的初始化函式: ```python def __init__(self, in_channel): super(Subsampling, self).__init__() self.pool = nn.AvgPool2d(2) self.in_channel = in_channel F_in = 4 * self.in_channel self.weight = nn.Parameter(torch.rand(self.in_channel) * 4.8 / F_in - 2.4 / F_in, requires_grad=True) self.bias = nn.Parameter(torch.rand(self.in_channel), requires_grad=True) ``` 這個函式中的引數含義其實一目瞭然,並且其中也有一些我們在上一篇的LeNet-1989中提到過的讓人感覺熟悉的內容,但是······這個多出來的Parameter是什麼鬼啦(╯‵□′)╯︵┻━┻。別急別急,我們來一點點的看一下吧。 對於父類的初始化函式呼叫沒什麼好說的。我們先來看下面的這一行: ```python self.pool = nn.AvgPool2d(2) ``` 我們之所以定義 self.pool 這個成員,是因為從上面我們的那個池化層的公式上來看,我們完全可以先對我們要求解的區域先求一個平均池化,再對這個結果做一個線性處理,從數學上是完全等價的,並且這也免得我們自己實現相加功能了,豈不美哉?(腦補一下三國名場景)。並且在論文中指定的池化層的核的尺寸是[2, 2],所以有了上面的定義方法。 然後是下面的和那個Parameter相關的程式碼: ```python self.weight = nn.Parameter(torch.rand(self.in_channel) * 4.8 / F_in - 2.4 / F_in, requires_grad=True) self.bias = nn.Parameter(torch.rand(self.in_channel), requires_grad=True) ``` 從引數的名稱上看我們很容易知道weight和bias就是我們的可學習權重和偏置,但是為什麼我們需要定義一個Parameter,而不是像以前一樣只使用一個tensor完事?這裡就要簡單介紹一下nn.Module這個類了。在這個類中有三個比較重要的字典: * \_parameters:模型中的引數,可求導 * \_modules:模型中的子模組,就類似於在自定義的網路中加入的Conv2d()等 * \_buffer:模型中的buffer,在其中的內容是不可自動求導的,常常用來存一些常量,並且在之後C3層的構造中要用到。 當我們向一個自定義的模型類中加入一些自定義的引數的時候(比如上面的weight),我們必須將這個引數定義為Parameter,這樣在進行self.weight = nn.Parameter(...)這個操作的時候,pytorch會將這個引數註冊到我們上面提到的字典中,這樣在後續的反向傳播過程中,這個引數才會被計算梯度。當然這裡只是十分簡單地說一下,詳細的內容的話推薦大家看兩篇部落格,連結放在下