小白的經典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再見吧( ̄▽ ̄)