小白經典CNN論文復現系列(一):LeNet1989

JacobDale發表於2020-12-29

小白的經典CNN復現系列(一):LeNet-1989

之前的浙大AI作業的那個系列,因為後面的NLP的東西我最近大概是不會接觸到,所以我們先換一個系列開始更新部落格,就是現在這個經典的CNN復現啦(。・ω・。)

在開始正式內容之前,還是有些小事情提一下,免得到時候評論區的dalao們對我進行嚴格的批評教育······

  • 首先呢,我會盡可能地按照論文裡面的模型引數進行復現,論文裡面說的什麼我就寫什麼。但是由於我本人還是個小白,對於有些演算法(比如什麼擬牛頓法什麼的)實在是有點苦手,而且CNN也基本上就只使用一階的優化方法,所以有些我感覺沒有必要復現的東西我就直接用一些其他的辦法替代啦,別介意哈

  • 然後呢,因為我這邊硬體裝置有限,就只有一張1080Ti的卡,所以如果模型比較複雜並且資料集太大的話,我可能就只是把模型的結構復現一下,具體的訓練就愛莫能助了呢,畢竟,窮是原罪┓( ´∀` )┏

  • 另外,由於大部分論文實際上並沒有將所有的處理手段全都寫在論文裡面,所以有的時候復現出來的結果和實際的論文裡面說的結果可能會有一些差距,但是這個真的沒有辦法,除非去找作者把原始碼搞過來,而且畢竟權重的初始化是隨機的,也就是說即便拿到原始碼,也不一定能跑出論文裡面的最好結果,所以,大家就看看思路就好了唄

  • 最後呢,因為我知識儲備還不是很夠,所以有些描述可能會不太正式,甚至有些小錯誤,所以如果出現這種情況,麻煩各位dalao在評論區溫柔地指點一下,反正要是你噴我,我不理你就是了┓( ´∀` )┏

好啦好啦,開始正題吧。一般來說,大家認為的非常經典的打頭的神經網路是由LeCun提出來的LeNet-5,而且這個網路在當時的MNIST資料集上的表現也不錯。但是實際上這個網路也有他的初始版本,就是這篇部落格要講的LeNet-1989了,雖然實際這個網路並不叫LeNet,這個結構命名是我在看CSDN上的一篇部落格的時候那個博主這麼寫的。在我看來為什麼那個博主稱這個網路叫LeNet-1989呢?實際上仔細看下這個網路的結構的話,大致的結構和後來的LeNet-5的結構已經十分相似了,只是深度、池化、輸出形式、訓練方法等小細節不太一樣,為了能更好地瞭解後面的LeNet-5,我選擇了這個網路作為一個入門參考(雖然復現的時候才發現全是坑(T▽T))。

具體的論文題目是《Backpropagation Applied to Handwritten Zip Code Recognition》,在網上應該是還能找得到這篇文章的,雖然這篇文章已經很老了(1989年比我老多了,emmmm我應該沒有暴露年齡,大概)

這篇文章實際上算是LeCun相當早的關於卷積神經網路形式的論文了,而且在這篇文章中,權重初始化、權重共享理念都有涉及,雖然並沒有在理論上給出嚴格的推導和證明,但是我們可以看出來的是,在這個階段LeCun對於卷積神經網路的具體結構已經有了一個比較完善的概念了,而且這篇文章中指出的初始化方法、啟用函式形式以及卷積核的尺寸等等也和之後的LeNet-5是基本一致的。

資料集部分:

在這篇文章中使用的實際資料集是當時美國的手寫郵政編碼,但是這個原始的資料集我是找不到了,所以我就使用了基於這個資料集進行調整以及再整理後得到的資料集MNIST了,這個資料集也是後來LeNet-5用的嘛,就當是和論文一樣好咯┓( ´∀` )┏

實際上pytorch中是提供了MNIST資料集的下載以及載入,所以這個還是蠻方便的,但是還有一個問題,那就是論文裡面提到,實際訓練網路使用的圖片是16 x 16,並且將灰度值的範圍通過變換轉換到了[-1, 1]的範圍內,而Pytorch提供的MNIST資料集裡面是尺寸為28 x 28,畫素值為[0, 255]的圖片,因此在實際進行網路訓練之前,我們要對資料集中的資料進行簡單的處理,這部分到後面的程式碼部分在說吧。

網路結構部分:

這篇文章使用的網路的基本結構和之後的LeNet-5除了深度以及一些小細節之外基本上是一模一樣了,具體的結構直接看圖啦:
下面我們對這個網路結構進行一個簡單地分析:

整個網路由H1、H2、H3以及output層構成,每一層的具體結構以及功能我下面會說啦:

  • H1層:由5 x 5的卷積核以及對應的偏置進行特徵圖的計算,輸入的圖片的尺寸如果寫成Pytorch的預設的輸入資料格式的話(在這裡先不考慮batch_size這個維度),應該是[channels, height, width] = [1, 16, 16],卷積核會將這個輸入資料輸出成[12, 8, 8]的形式。但是這裡就有問題了,因為原論文中並沒有給出卷積運算時使用的stride以及padding,而根據這個資料並且考慮計算機進行整數運算時候的取整操作,這個是有無窮多的解的······,所以我在復現這裡的時候,就選了最簡單的引數,stride = (2,2),padding = (2,2),考慮到取整操作,這樣是可以得到論文中所說的特徵圖的尺寸的。

  • H2層:同樣的,這裡是由5 x 5的卷積核以及對應的偏置進行特徵圖的計算,輸入的圖片的尺寸是之前的H1層的[12, 8, 8],卷積核會將這個輸入資料輸出成[12, 4, 4]的尺寸。然後這裡就出現了和上面一層同樣的問題,他沒給說明使用的stride和padding是多少,而且有無窮多組解······真的看到這裡我已經打算放棄復現這個論文了,但是,沒辦法畢竟都看到這裡了,自己作的死,跪著也要作完TAT。這裡選取的引數也是stride = (2,2),padding = (2,2),大家可以計算一下,這個和給出的那個尺寸是一致的。並且這裡和LeNet-5一樣,用到了一個比較特別的計算方式,當我們計算特徵圖的時候,並沒有直接拿所有的H1層的輸出作為輸入,而是每一次都從那12個特徵圖中調出8個來進行計算,但是,理由並沒有說,而且很可氣的就是裡面有一句話

    ···according a scheme that will not be described here.
    "我知道,但我就是不說,氣不氣"

我感覺我撕了論文的心都有了······,所以這裡我們不理他,就直接用正常的卷積來做,反正相關的東西在後面的LeNet-5裡面也有說到底怎麼選,這裡就先這樣。

  • H3層:到這一層卷積結束,進入到全連線層的範圍。由於我們的輸入特徵圖的尺寸是[12, 4, 4],將這個圖片轉換成向量以後的維度是12 x 4 x 4 = 192,並且要求輸出的尺寸是30,因此這裡的線性層的尺寸是192 x 30
  • output層:在這一層要進行分類輸出啦,所以我們的線性層理所當然的是30 x 10

網路結構就是這些啦,是不是很簡單?而且和之後的LeNet-5也是很像呢。其實這篇文章我是覺得,如果他能把裡面的一些東西說得更加清楚的話,其實蠻適合初學者進行復現的,然而就是因為一大堆東西沒有說清楚,結果整出了一個月球表面來,到處都是坑。

訓練相關引數部分:

在關於模型的訓練上,主要有以下幾件事需要注意:

  • 使用的基本思路是隨機梯度下降(SGD),注意這裡的SGD不是那種有mini-batch的,而是就真的每次就使用一個樣本進行引數的更新,這也是為什麼之前我在說圖片尺寸的時候讓大家先不要考慮batch_size的問題,因為這個是1。

  • 關於更新方法的問題,在這篇論文裡面使用的是二階精度的方法,具體的更新演算法在LeCun的《Improving the Convergence of Back-Propagation Learning with Second-Order Methods》中有介紹,實際上就是對BFGS演算法進行了一些改進,大家有興趣可以看一下這篇文章。但是,實際上在後來的LeNet-5的這篇文章中,LeCun指出,在這種較大資料量的訓練上面,用二階方法的人都是吃飽了沒事幹的鐵憨憨(我罵我自己.jpg),所以在這篇復現裡面,我們就採用一階方法的SGD。

  • 使用的損失函式為MSELoss,但是這部分我沒看懂他說的輸出部分用place coding是個啥······所以這個部分我就假設他用的是one-hot編碼啦,如果評論區有大佬能夠指點一下這個place coding到底是啥的話,我到時候再抽時間把這個部分重新搞一下。

  • 訓練代數為23,因為原文使用的引數更新方法是二階方法所以不用人為設定學習率(這一部分在2017年的CS231n中是有說明的,建議大家直接去看一下網課),但是我們這裡用的是一階的方法,所以需要設定學習率,並且訓練代數也要相對地增加一些,因為一階方法的收斂畢竟還是相對較慢。

各部分程式碼簡析

我們先把需要用到的模組啥的全都搞到一起吧

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets
from torchvision import transforms as T
from torch.utils.data import DataLoader

import matplotlib.pyplot as plt

首先是關於資料的處理部分。相關的內容在我的浙大AI作業系列的口罩識別部分有詳細說明,在這裡就不具體解釋原理了,直接貼程式碼

picProcessor = T.Compose([
    T.Resize((16, 16)), #圖片尺寸的重整
    T.ToTensor(), #將圖片轉化為畫素值為[0, 1]的tensor
    T.Normalize(
        mean = [0.5],
        std = [0.5]
    ), #將圖片的資料範圍從[0, 1]轉換為[-1, 1]
])

有了這個圖片的轉換器之後,我們要載入一下我們的資料集,並且用這個轉換器進行圖片的處理。由於Pytorch提供了自己的MNIST資料的載入方式,因此我們在這裡直接就用Pytorch提供的方法就好了。

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)

詳細的關於datasets.MNIST的使用方法,建議大家查一下官方文件以及自行百度,這裡就不多做解釋了。

在介紹下一步分的程式碼之前,我們必須要先來看看我們之後要用的被載入進來的資料長什麼鬼樣子,那我們就來拿出一個資料來看一下資料長啥樣好了。

img, label = mnistTrain[0]
print(type(img)) #tensor
print(img) #圖片對應的畫素值矩陣
print(type(label)) #int
print(label) #5,圖片的標籤

也就是說,資料集裡面圖片給的是一個tensor,但是標籤給的是int,所以之後我們要自己把讀出來的標籤轉化成我們想要one-hot向量。

由於我們訓練集有60000張圖片,所以如果用cpu進行訓練的話可能要花很長的時間,能用GPU的話還是用GPU進行訓練吧,這裡給出一個通用程式碼,有沒有GPU都可以的。

device = torch.device('cuda:0') if torch.cuda.is_available() else torch.device('cpu') #如果電腦有N卡的GPU,就可以把模型放GPU上,否則就放CPU上

接下來終於到我們的重點啦啊啊啊啊啊!現在呢我們要開始構建我們的神經網路了。為了讓小夥伴們都能看懂,所以這部分我們就老老實實地一步一步構造,也不寫什麼用Sequential全都包起來的騷操作,就一層一層地來:

H1:in_channel = 1, out_channel = 12, kernel_size = (5, 5), stride = (2, 2), padding = 2

啟用函式:1.7159Tanh(2/3 * x)

self.conv1 = nn.Conv2d(1, 12, 5, stride = 2, padding = 2)
self.act1 = nn.Tanh() #這一部分先用著Tanh(),等到後面寫forward函式的時候再把係數乘上去

H2:in_channel = 12, out_channel = 12, kernel_size = (5, 5), stride = (2, 2), padding = 2

啟用函式:同上

self.conv2 = nn.Conv2d(12, 12, 5, stride = 2, padding = 2)
self.act2 = nn.Tanh()

H3:全連線層: 192 * 30

啟用函式:同上

self.fc1 = nn.Linear(192, 30)
self.act3 = nn.Tanh()

output:全連線層:30 * 10

啟用函式:同上

self.fc2 = nn.Linear(192, 30)
self.act4 = nn.Tanh()

我們需要把剛剛的這一堆全都放在自定義的LeNet1989類的建構函式裡面,到時候所有的程式碼我會全部在最下面整理一下的,所以先別急吖。

在構造完基本構造以後,別忘了論文裡面還說了,我們要對權重做一個基本的初始化,所以我們還要敲下面的程式碼:

for m in self.modules():
	if isinstance(m, nn.Conv2d):
		F_in = m.kernel_size[0] * m.kernel_size[1] * m.out_channels
		m.weight.data = torch.rand(m.weight.data.size()) * 4.8 / F_in - 2.4 / F_in
	if isinstance(m, nn.Linear):
		F_in = m.out_features
		m.weight.data = torch.rand(m.weight.data.size()) * 4.8 / F_in - 2.4 / F_in

關於這段程式碼,有一些需要注意的事情:

  • 首先,當我們自定義的網路結構中有很多的基本結構的時候(比如說這個例子我們有兩個卷積還有兩個全連線),為了能夠訪問全部的基本結構,我們可以用Module的成員函式modules(),會返回一個包括自身內部所有基本結構的可迭代結構

  • 在我們基本的結構,比如卷積層中,通過檢視原始碼我們可以知道,裡面是有weight成員和bias成員分別表示權重和偏置的

  • 對於我們的所有的神經網路的基本模型(卷積層以及線性層),引數本來應該會直接存在tensor裡面,但是為了在模型中進行一些中間結果的暫存(比如RNN的隱藏層輸出),所有的引數會被打包放進一個叫做Parameter的類裡面,Paramter裡面有data成員用來儲存tensor的資料,而還有一個成員requires_grad用來確定是否該引數可以求梯度,不過因為沒用到所以先不提,感興趣的小夥伴可以去看一下官方網站上的關於torch.nn.Module以及torch.nn.parameter.Parameter的原始碼。(說這麼多主要是為了解釋 m.weight.data的含義)

  • tensor在進行rand()初始化的時候,生成的隨機數滿足以[0, 1)為區間的均勻分佈,想要轉換成我們想要的分佈的話就要自己做一些簡單的變換。具體來說,假設我們要轉換為[a, b),就需要進行下面的轉換:x * (b-a) + a,這樣的話就從[0, 1)轉換為[a, b)了

上面的一大坨的程式碼就是我們定義的神經網路的構造以及初始化部分了。對於我們自行構造的神經網路結構來說,下面一個很重要的函式就是forward函式了,沒有這個函式我們的網路就沒得跑。但是由於我們前面結構已經定義好了,並且裡面根本沒有什麼複雜的東西,所以這個模型的forward函式其實蠻好寫的。(關於為什麼一定要有forward函式,我在關於浙大AI的口罩識別作業裡有簡單的說明,或者大家可以讀一下我之前推薦的《Deep Learning with Pytorch》)

def forward(self, x):
    x = self.conv1(x)
    x = 1.7159 * self.act1(2.0 * x / 3.0) #這裡就是我們之前說的,實際論文用的啟用函式並不是簡單的Tanh

    x = self.conv2(x)
    x = 1.7159 * self.act2(2.0 * x / 3.0)

    x = x.view(-1, 192) #這一步是由於我們實際上在上面的一層中輸出的x的維度為[12, 4, 4]
    					#我們必須把它變成[1, 192]的形式才能輸入到全連線層。
						#詳細的原因我在之前的浙大AI口罩作業的部落格裡有提到的

    x = self.fc1(x)
    x = 1.7159 * self.act3(2.0 * x / 3.0)

    x = self.fc2(x)
    out = 1.7159 * self.act4(2.0 * x / 3.0)

    return out

現在資料、模型結構都已經搞定了,接下來我們要做的就是訓練我們的模型啦,大家鼓掌慶祝下唄(๑¯∀¯๑)
實際上訓練函式部分沒有什麼難點,大致的內容我在浙大AI口罩作業裡面基本都說得比較詳細了,所以基本沒什麼難點呢。總之我們來一點點看一下我們的程式碼吧。

lossList = []
testError = []

這一部分主要是設定全域性變數,來儲存我們在訓練過程中得到的損失函式值以及在測試集上的錯誤率,其實看一眼變數名就能猜出來是幹啥的了嘛

接下來使我們的訓練函式:

def train(epochs, model, optimizer, scheduler:bool, loss_fn, trainSet, testSet):
···

epochs:訓練的代數
model:我們定義的模型物件
optimizer:定義的優化器物件
scheduler:這就是和之前內容不太一樣的東西啦,確定是否進行學習率的變化
loss_fn:lossfunction,損失函式
trainSet:訓練集
testSet:測試集

在訓練函式部分,我們需要做的就是一下幾點:

  • 訓練:
    • 將資料從訓練集裡面取出來
    • 將標籤轉換為one-hot格式
    • 將資料放到之前指定的device中,GPU優先,沒有就放CPU
    • 計算輸出、損失並進行梯度下降
  • 測試:
    • 暫時取消梯度更新
    • 在測試集上資料處理、裝置指定
    • 輸出,和標籤進行比較
  • 學習率的改變:這一部分主要是為了之後的LeNet-5的復現做準備,因為在LeNet-5中,使用的學習率是和當前的訓練輪數相關的,具體的程式碼內容我會放在這裡,但是我不會解釋,等到後面的LeNet-5再進行詳細地說明

好了我們來吧程式碼放上來吧:

trainNum = len(trainSet)
testNum = len(testSet)
for epoch in range(epochs):
    lossSum = 0.0
    
    #訓練部分
    for idx, (img, label) in enumerate(trainSet):
        x = img.unsqueeze(0).to(device)
        
        #將標籤轉化為one-hot向量
        y = torch.zeros(1, 10)
        y[0][label] = 1.0
        y = y.to(device)
		
        #梯度下降與引數更新
        out = model(x)
        optimizer.zero_grad()
        loss = loss_fn(out, y)
        loss.backward()
        optimizer.step()

        lossSum += loss.item()


	lossList.append(lossSum / trainNum)
	
    #測試部分,每一個epoch訓練完畢後就對測試集進行一下測試,儲存一個正確率
    with torch.no_grad():
    	errorNum = 0
        for img, label in testSet:
        	x = img.unsqueeze(0).to(device)
        	out = model(x)
        	_, pred_y = out.max(dim = 1)
        	if(pred_y != label): errorNum += 1
   		testError.append(errorNum / testNum)
	
	#這一段的程式碼就是用來改變學習率的,但是先放著,這裡還不講,因為裡面涉及到判斷,如果覺得會影響效能可以把這裡全都註釋掉
    if scheduler == True:
        if epoch < 2:
            for param_group in optimizer.param_groups:
            param_group['lr'] = 5.0e-4
        elif epoch < 5:
            for param_group in optimizer.param_groups:
            param_group['lr'] = 2.0e-4
        elif epoch < 8:
            for param_group in optimizer.param_groups:
            param_group['lr'] = 1.0e-4
        elif epoch < 12:
            for param_group in optimizer.param_groups:
            param_group['lr'] = 5.0e-5
        else:
            for param_group in optimizer.param_groups:
            param_group['lr'] = 1.0e-5

和之前的浙大AI口罩識別作業的部落格裡面有明顯區別的地方出現啦,不知道細心的小夥伴們有沒有發現呢?好啦好啦不賣關子了,出現差異的程式碼是在訓練部分裡面:

x = img.unsqueeze(0).to(device)

回顧一下之前的程式碼,我們會發現我們當時就直接寫的to(device),根本沒有出現這個unsqueeze(0)吖,這啥意思啊?別急別急,馬上就說呀。

在之前的口罩識別的作業裡面,我們提到Pytorch接收資料的格式是有要求的,並且我們當時還介紹了一下view的原理,事實上unsqueeze這個函式也是用於改變資料的維度的。

我們曾經提到,Pytorch的接收的資料維度中,第0維一定是batch_size,然後後面才是我們每一個樣本的真實資料維度。之前的口罩作業中,我們使用了DataLoader,並且設定了batch_size = 32,在進行處理之後我們輸入網路的維度就是[batch_size = 32, channel, height, width],但是這一次由於我們並沒有採用DataLoader,而是直接從trainSet裡面取的資料,此時取出的資料就是[channel = 1, height = 16, width = 16]的img以及int型別的label,img中根本沒有batch_size的維度。

我們來看一下這個函式unsqueeze的名字,大概意思就是解壓,實際上就是在指定的維度上進行展開。在不考慮其他的引數的時候,這個函式大概長下面這個樣子:

unsqueeze(dim)

這個函式的實際意義是,在指定的維度dim的位置,增加一個 ‘1’,使得整個tensor被提高一個維度。對於我們的訓練部分中的img.unsqueeze(0),含義就是在第0維的位置前新增上一個 '1',這樣我們的圖片的維度就被強制轉換為[1, channel = 1, height = 16, width = 16]的形式了,正好1就代表我們batch_size = 1,就是隻有一張圖片。與這個函式對應的還有一個函式squeeze(dim),這個函式的功能就剛好相反了,暫時我們還用不到,等到之後用到了再說咯(我記得好像NLP作業裡面就用到了)

放下學習率變化的那部分程式碼不談,我們的訓練函式部分基本結束······那是不可能的,我們辛辛苦苦訓練的模型,還沒儲存呢。那就儲存一下唄┓( ´∀` )┏

torch.save(model.state_dict(), 'F:\\Code_Set\\Python\\PaperExp\\LeNet-1989\\epoch-{:d}_loss-{:.6f}_error-{:.2%}.pth'.format(epochs, lossList[-1], testError[-1])) #路徑自己指定喲,我這個只是我自己的路徑

接下來我們要做的,就是把程式碼全都放在一起,並且新增一些可以做的簡單的視覺化工作咯:

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets
from torchvision import transforms as T
from torch.utils.data import DataLoader

import matplotlib.pyplot as plt

'''
定義資料的初始化方法:
    1. 將圖片的尺寸強制轉化為 16 * 16
    2. 將資料轉化成tensor
    3. 將資料的灰度值範圍從[0, 1]轉化為[-1, 1]
'''

picProcessor = T.Compose([
    T.Resize((16, 16)),
    T.ToTensor(),
    T.Normalize(
        mean = [0.5],
        std = [0.5]
    ),
])

'''
資料的讀取和處理:
    1. 從官網下載太慢了,所以先重新指定路徑,並且在mnist.py檔案裡把url改掉,這部分可以百度,很容易找到的
    2. 使用上面的處理器進行MNIST資料的處理,並載入
'''

dataPath = "F:\\Code_Set\\Python\\PaperExp\\DataSetForPaper\\" #在使用的時候請改成自己實際的MNIST資料集路徑
mnistTrain = datasets.MNIST(dataPath, train = True,  download = False, transform = picProcessor) #首次使用的時候,把download改成True,之後再改成False
mnistTest = datasets.MNIST(dataPath, train = False, download = False, transform = picProcessor)

# 因為如果在CPU上,模型的訓練速度還是相對來說較慢的,所以如果有條件的話就在GPU上跑吧(一般的N卡基本都支援)
device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")

'''
神經網路類的定義
    1. 輸入卷積: in_channel = 1, out_channel = 12, kernel_size = (5, 5), stride = (2, 2), padding = 2
    2. 啟用函式: 1.7159Tanh(2/3*x)
    3. 第二層卷積: in_channel = 12, out_channel = 12, kernel_size = (5, 5), stride = (2, 2), padding = 2
    4. 啟用函式同上
    5. 全連線層: 192 * 30
    6. 啟用函式同上
    7. 全連線層:30 * 10
    8. 啟用函式同上
    
    按照論文的說明,需要對網路的權重進行一個[-2.4/F_in, 2.4/F_in]的均勻分佈的初始化
'''

class LeNet1989(nn.Module):
    def __init__(self):
        super(LeNet1989, self).__init__()
        
        self.conv1 = nn.Conv2d(1, 12, 5, stride = 2, padding = 2)
        self.act1 = nn.Tanh()
        self.conv2 = nn.Conv2d(12, 12, 5, stride = 2, padding = 2)
        self.act2 = nn.Tanh()
        
        self.fc1 = nn.Linear(192, 30)
        self.act3 = nn.Tanh()
        self.fc2 = nn.Linear(30, 10)
        self.act4 = nn.Tanh()
        
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                F_in = m.kernel_size[0] * m.kernel_size[1] * m.in_channels
                m.weight.data = torch.rand(m.weight.data.size()) * 4.8 / F_in - 2.4 / F_in
            if isinstance(m, nn.Linear):
                F_in = m.in_features
                m.weight.data = torch.rand(m.weight.data.size()) * 4.8 / F_in - 2.4 / F_in
        
    def forward(self, x):
        x = self.conv1(x)
        x = 1.7159 * self.act1(2.0 * x / 3.0)
        
        x = self.conv2(x)
        x = 1.7159 * self.act2(2.0 * x / 3.0)
        
        x = x.view(-1, 192)
        
        x = self.fc1(x)
        x = 1.7159 * self.act3(2.0 * x / 3.0)
        
        x = self.fc2(x)
        out = 1.7159 * self.act4(2.0 * x / 3.0)
        
        return out
        


lossList = []
testError = []

def train(epochs, model, optimizer, scheduler: bool, loss_fn, trainSet, testSet):

    trainNum = len(trainSet)
    testNum = len(testSet)
    for epoch in range(epochs):
        lossSum = 0.0
        print("epoch: {:02d} / {:d}".format(epoch+1, epochs)) #這段主要是顯示點東西,免得因為硬體問題訓練半天沒動靜還以為電腦當機了┓( ´∀` )┏
        
        #訓練部分
        for idx, (img, label) in enumerate(trainSet):
            x = img.unsqueeze(0).to(device)
            
            #將標籤轉化為one-hot向量
            y = torch.zeros(1, 10)
            y[0][label] = 1.0
            y = y.to(device)
            
            #梯度下降與引數更新
            out = model(x)
            optimizer.zero_grad()
            loss = loss_fn(out, y)
            loss.backward()
            optimizer.step()
            
            lossSum += loss.item()
            if (idx + 1) % 5000 == 0: print("sample: {:05d} / {:d} --> loss: {:.4f}".format(idx+1, trainNum, loss.item())) #同樣的,免得你以為電腦死了,順便看看損失函式,看看是不是在下降,如果電腦效能比較差,可以改成100,這樣每隔幾秒就有個顯示,起碼心裡踏實┓( ´∀` )┏
            
        
        lossList.append(lossSum / trainNum)
        
        #測試部分,每訓練一個epoch就在測試集上進行錯誤率的求解與儲存
        with torch.no_grad():
            errorNum = 0
            for img, label in testSet:
                x = img.unsqueeze(0).to(device)
                out = model(x)
                _, pred_y = out.max(dim = 1)
                if(pred_y != label): errorNum += 1
            testError.append(errorNum / testNum)
        
        #這段程式碼是用來改變學習率的,現在先不用看,如果覺得影響效能,可以全都註釋掉
        if scheduler == True:
            if epoch < 2:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 5.0e-4
            elif epoch < 5:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 2.0e-4
            elif epoch < 8:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 1.0e-4
            elif epoch < 12:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 5.0e-5
            else:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 1.0e-5

    torch.save(model.state_dict(), 'F:\\Code_Set\\Python\\PaperExp\\LeNet-1989\\epoch-{:d}_loss-{:.6f}_error-{:.2%}.pth'.format(epochs, lossList[-1], testError[-1])) #模型的儲存路徑記得改成自己想要的喲
            

if __name__ == '__main__':

    model = LeNet1989().to(device)
    loss_fn = nn.MSELoss()
    optimizer = optim.SGD(model.parameters(), lr = 1.0e-3)
    
    scheduler = False #因為我們還不用設定學習率改變,所以設定False
    				  #當然啦,如果想要試試效果也可以改一下,但是我測試過要訓練很多代才能達到論文中的提到的結果
    
    epochs = 40
    
    train(epochs, model, optimizer, scheduler, loss_fn, mnistTrain, mnistTest)
    plt.subplot(1, 2, 1)
    plt.plot(lossList)
    plt.subplot(1, 2, 2)
    testError = [num * 100 for num in testError]
    plt.plot(testError)
    plt.show()

訓練及結果

我這邊使用的是Windows10系統,顯示卡是1080Ti,實際上這部分的程式碼實測也是可以在Linux上執行的(Ubuntu可以,CentOS沒試過,不過這個程式碼不涉及到跨平臺的問題,所以應該沒問題),只是需要把各部分涉及到檔案路徑的地方全都改成Linux的對應路徑以及格式就行了。然後我是用notepad++寫的,如果是用Pycharm或者VScode的,把專案結構和路徑自己搞定就行啦。

經過漫——長——地等待之後,我們的模型終於訓練結束了。訓練結果看下面啦:

損失函式曲線:

在測試集上的錯誤率曲線(%):

可以發現,損失函式基本收斂,到最後一輪損失函式值為0.010078,然後在測試集上的錯誤率是4.24%,這兩個結果和論文上對應的結果······好吧畢竟在訓練方式和資料集上有一些差距,所以結果上有點區別是很正常的。(論文上訓練集損失函式值為2.5e-3,測試集錯誤率為5.0%,而且從曲線趨勢上講,我們的復現工作如果降低學習率再多跑幾個epoch可能還能繼續優化,但是實在是沒必要┓( ´∀` )┏)

然後呢為了驗證一下我們的模型到底行不行,我們可以從測試集裡面隨機挑幾個數送到模型裡面,看一看輸出的結果和實際結果。如果還想驗證一下模型的真實效能,也可以自己手寫幾張圖片然後傳輸到模型裡面,看看能輸出個什麼鬼東西。如果要自己手寫幾張圖片傳輸到模型裡面,那需要注意下面的幾個問題:

  • 自己拍攝或者是在畫圖裡面畫的圖片,實際上是三通道的RGB圖片,而模型接收的引數是單通道的灰度圖,所以你需要用PIL庫把圖片轉化為單通道灰度圖,相關的方法可以百度一下啦(convert('L'),搜這個函式喲)
  • 你可以從MNIST的資料集裡面隨便找出一個輸出一下,發現裡面所有的圖片都是黑底白字的,你可以嘗試一下如果你把自己寫的白底黑字的灰度圖傳進去,基本正確率是0吧(大概只有0,1,8能夠正確識別出來)。所以要想正確的得到識別結果,你需要把上一步轉化的白底黑字的灰度圖轉化成黑底白字的灰度圖,具體做法你可以先將PIL圖(假設變數名為image)轉化為numpy陣列,用255 - image之後(numpy的廣播操作),再轉回成PIL圖,這樣就是黑底白字了(別問我為啥知道要這麼搞,問就是這坑我掉進去過)
  • 經過上面的處理之後,反正我隨便手寫了幾十個數,因為字不算醜,所以都識別對了,大家也可以試試。草書大師們先往後稍稍,讓字好看的人先來

這一部分因為實際上和訓練部分非常像,我們就不貼程式碼了,就在這裡簡單說一下思路:

  • 定義模型:建立一個LeNet1989的物件
  • 載入模型:將之前儲存的模型使用load_state_dict()函式進行載入,這部分不清楚的查一下資料吧
  • 讀入資料並進行處理:將資料讀進來,並且按照訓練的時候的那種格式進行處理,注意一下上面提到的坑
  • 送入模型獲得結果:這個就和訓練函式裡面的with torch.no_grad()後面的部分基本一樣

結果反思

關於這篇論文的復現基本上就這些東西了,但是裡面還是有一些東西有待思考和解決:

  • 權重的初始化方法為什麼是這樣的,這樣做合理嗎?(事實上不是很合理,這部分可以參考Xavier初始化以及Hecaiming初始化的論文,因為我還沒看,所以不好說什麼)

  • 啟用函式選取這樣的形式的原因是什麼?(這部分我記得好像在LeNet-5的論文附錄裡有,到時候復現那篇文章的時候再細說好了)

  • 輸出的place coding沒看懂,所以先拿one-hot湊合用的,如果有大佬知道這到底是啥的麻煩評論區指點一下

  • 使用了一階方法來進行引數更新,這一部分和原論文是不一致的,並且需要引入學習率這一超引數。雖然也訓練出一個差不多的模型,損失函式和錯誤率基本收斂,但是這個收斂到底是因為真的收斂到了區域性最優值附近,還是因為學習率有點大導致在一個對稱區間反覆橫跳?(事實上學習率確實是有一點大,我們可以考慮使用那個學習率的調整部分,讓學習率在訓練後期下降到一個較小值)

  • 每訓練一個樣本就進行一次引數更新,沒有使用mini-batch。事實上mini-batch的思想有一點像引數估計,也就是使用樣本均值來估計整體的期望(回憶一下梯度下降的公式形式,就是對所有樣本的梯度取均值嘛),然而如果對每一個樣本都進行引數更新,就相當於隨機抽個樣本用來取代期望,怎麼想都不太合理嘛,雖然LeNet-5也是這麼幹的,但是從統計上講這是不對的吖,這也是後面的大多數論文都使用mini-batch的原因吧,並且這也涉及到batch_size怎麼選取的問題,反正挺麻煩的

  • 訓練的時候使用的SGD方法,但是實際上這個方法對於損失函式的“鞍點”以及“區域性最優值”的表現會比較差,尤其是“鞍點”問題,這部分的原因大家可以去看一下史丹佛的CS231N,講得還是比較清楚的,解決方案就是使用比如動量、Adam等優化的方法,不過這裡因為是復現,所以就先這樣用(而且Pytorch裡面有現成的包,就把那個optimizer的部分改改就好咯)

  • 關於那個“我就不告訴你”的那個部分沒有實現,不過這一部分在後面的LeNet-5裡面有說,所以等到那個時候再說好了

整體去看程式碼的話,其實大佬可能會對這個程式碼結構嗤之以鼻,因為很多可以複用的地方並沒有複用,可以寫到一塊的地方非要分開,matplotlib畫圖既沒有軸標題也沒有圖示題,連圖例、顏色都沒有。怎麼說呢,這些東西要是真的寫要發表的論文和程式碼,我肯定不這麼寫,我也是知道該怎麼寫的,只是我這個復現系列的部落格的目的就是為了讓和我一樣轉專業搞DL的小白萌新夥伴們能夠很清晰的看到復現思路與程式碼結構,至於美觀性和效能問題,emmmmm······大家懂了原理之後自己自然知道該怎麼搞定咯。

到這裡,這篇論文的復現就基本結束了。論文字身寫的相當簡單,但事實上覆現的時候就會發現裡面一大堆的坑······不過在復現過程中對每一部分的機理進行一些簡單的思考,其實也是能有一些自己的理解與收穫的,其實好多發文章的想法(idea)就是在復現論文的時候突發奇想,然後搞搞理論寫寫模型然後整出來的。我也還就是一個轉專業剛剛入行的小白,看著桌子上擺的“待看論文”的小山······emmmmm,其實我內心是崩潰的TAT,總之先慢慢學習吧。那這篇就先這樣,我們之後LeNet-5再見吧( ̄▽ ̄)

相關文章