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

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

各位看官大人久等啦!我胡漢三又回來辣(不是html

最近由於到期末考試周,再加上老闆臨時給安排了個任務,其實LeNet-5的復現工做早都搞定了,結果沒時間寫這個博客,今天總算是抽出時間來把以前的工做簡單總結了一下,而後把這個文章簡單寫了一下。python

由於LeNet-5這篇文章實在是太——長——了,再加上內容稍稍有那麼一點點複雜,因此我打算大體把這篇博客分紅下面的部分:編程

  • 論文怎麼讀:由於太多,因此論文裏面有些部分能夠選擇性略過網絡

  • 論文要點簡析:簡單說一下這篇文章中提出了哪些比較有意思的東西,而後提一下這個論文裏面有哪些坑app

  • 具體分析與復現:每個部分是怎麼回事,應該怎麼寫代碼框架

  • 結果簡要說明:對於復現的結果作一個簡單的描述機器學習

  • 反思:雖然模型很經典,可是實際上仍是有不少的考慮不周的地方,這些也是後面成熟的模型進行改進的地方ide

在看這篇博客以前,但願你們能先知足下面的兩個前置條件:函數

  • 對卷積神經網絡的大體結構和功能有必定的瞭解,不是完徹底全的小白性能

  • 對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都能運行,只須要把文件路徑改爲對應操做系統的格式就行

在開始寫代碼以前,一樣的,把咱們須要的模塊啥的,一股腦先都裝進來,省得後面有什麼東西給忘記了:

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模式進行調整,這個等到到時候講卷積層的時候再詳細說好了。

所以考慮到上面的因素,咱們的圖片處理器應該長下面的這個鬼樣子:

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的代碼基本上就同樣的吖,就很少解釋了。

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就幾個小時,因此若是實在沒條件的話,就跟着我把代碼敲一遍,看懂啥意思就好了,這個我真的沒辦法┓( ´∀` )┏

device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")

網絡結構部分

LeNet-5的結構其實仍是蠻經典的,不過在這裏仍是再爲你們截一下圖,而後慢慢解釋解釋每層是怎麼回事吧。

由於這裏面的東西其實蠻多的,我怕像上一篇同樣在最後才把代碼一會兒放出來會讓人記不住前面講過啥,因此這部分就每個結構下面直接跟上對應的代碼好了。

總體

咱們的神經網絡類的名字就定義爲LeNet-5好了,大體定義方法以下:

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層的構造代碼應該是下面的樣子:

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來實現咱們的池化層:

class Subsampling(nn.Module)

首先看一下咱們的初始化函數:

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是什麼鬼啦(╯‵□′)╯︵┻━┻。別急別急,咱們來一點點的看一下吧。

對於父類的初始化函數調用沒什麼好說的。咱們先來看下面的這一行:

self.pool = nn.AvgPool2d(2)

咱們之因此定義 self.pool 這個成員,是由於從上面咱們的那個池化層的公式上來看,咱們徹底能夠先對咱們要求解的區域先求一個平均池化,再對這個結果作一個線性處理,從數學上是徹底等價的,而且這也省得咱們本身實現相加功能了,豈不美哉?(腦補一下三國名場景)。而且在論文中指定的池化層的核的尺寸是[2, 2],因此有了上面的定義方法。

而後是下面的和那個Parameter相關的代碼:

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會將這個參數註冊到咱們上面提到的字典中,這樣在後續的反向傳播過程當中,這個參數纔會被計算梯度。固然這裏只是十分簡單地說一下,詳細的內容的話推薦你們看兩篇博客,連接放在下面:

https://blog.csdn.net/u012436149/article/details/78281553

http://www.javashuo.com/article/p-gfbtymgr-hw.html

而後代碼裏面的初始化方法什麼的,在前一篇LeNet-1989裏面已經提到過了,就很少說了。

接下來是forward函數的實現:

def forward(self, x):
	x = self.pool(x)
	outs = [] #對每個channel的特徵圖進行池化,結果存儲在這裏

	for channel in range(self.in_channel):
		out = x[:, channel] * self.weight[channel] + self.bias[channel] #這一步計算每個channel的池化結果[batch_size, height, weight]
		outs.append(out.unsqueeze(1)) #把channel的維度加進去[batch_size, channel, height, weight]
	return torch.cat(outs, dim = 1)

在這裏比較須要注意的部分是for函數以及return部分的內容,咱們一樣一塊一塊展開進行分析:

for channel in range(self.in_channel):
	out = x[:, channel] * self.weight[channel] + self.bias[channel]
	outs.append(out.unsqueeze(1))

前面提到過,咱們在每個前面輸出的特徵圖上計算平均池化的時候,使用的可訓練參數都是不同的,都須要各自進行訓練,所以咱們須要作的是把每個channel的特徵圖都取出來,而後作一個池化操做,全部的channel都池化完畢以後咱們再拼回去。

假設咱們的輸入的尺寸爲x = [batch_size, c, h, w],咱們的操做步驟應該是這樣的:

  • 那麼咱們須要作的是把每個channel的特徵圖取出來,也就是x[:, channel] = [batch_size, h, w];

  • 對取出來的特徵圖作池化:out = x[:, channel] * self.weight[channel] + self.bias[channel]

  • 把特徵圖先放在一塊兒(拼接是在return裏面作的,這裏只是先放在一塊兒),爲了讓咱們的圖可以拼起來,須要把池化的輸出結果升維,把channel的那一維加進去。以前咱們提到out的維度是[batch_size, h, w],channel應該加在第一維上,也就是outs.append(out.unsqueeze(1))

unsqueeze操做也在之前的博客中有寫過,就很少說了。

接下來是return的部分

return torch.cat(outs, dim=1)

在這裏出現了一個新的函數cat,這個函數的實際做用是,將給定的tensor的列表,沿着dim指定的維度進行拼接,這樣咱們從新獲得的返回值的維度就回復爲[batch_size, c, h, w]了。具體的函數用法能夠先看看官方文檔,而後再本身實踐一下,不是很難理解的。

至此Subsampling類就構建完畢了,每一個部分都搞清楚之後,咱們把類裏面全部的代碼都拼到一塊兒看一下吧:

class Subsampling(nn.Module):
    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)
        
    def forward(self, x):
        x = self.pool(x)
        outs = [] #對每個channel的特徵圖進行池化,結果存儲在這裏
        
        for channel in range(self.in_channel):
            out = x[:, channel] * self.weight[channel] + self.bias[channel] #這一步計算每個channel的池化結果[batch_size, height, weight]
            outs.append(out.unsqueeze(1)) #把channel的維度加進去[batch_size, channel, height, weight]
        
        return torch.cat(outs, dim = 1)

每個小部分都搞清楚之後再來看這個總體,是否是就清楚多啦!若是還有地方不太明白的話,能夠把這部分代碼多讀幾遍,而後多查一查官方的文檔,這個裏面基本是沒有什麼特別難的地方的。

這裏是定義一個這樣的池化層的類,用於複用,由於後面的S4的原理和這個是一致的,只是輸入輸出的維度不太同樣。對於S2來講,先不考慮batch_size,因爲從C1輸出的尺寸爲[6, 28, 28],所以咱們進行定義時是按照以下方法定義的:

self.S2 = Subsampling(6)

而且從池化層的核的尺寸來看,獲得的池化的輸出最終的尺寸爲[6, 14, 14]。

須要注意的是,在這一層後面有一個激活函數,但這裏有一個小小的問題,論文裏寫的激活函數是Sigmoid,但我用的不是,具體緣由後面再說。

C3層

好傢伙剛送走一個麻煩的傢伙,如今又來一個。這部分就是以前在 「論文要點簡析」 部分提到的花裏胡哨的特殊設計的卷積層啦。講道理寫到如今我都感受我腱鞘炎要犯了······

在介紹這部分代碼以前,仍是先對整個結構的輸入輸出以及基本的參數進行簡單的分析:

  • 輸入尺寸:在前一層S2的輸出尺寸爲[6, 14, 14]

  • 輸出尺寸:要求的輸出尺寸是[16, 10, 10]

  • 卷積層參數:要求的卷積核尺寸是[5, 5],沒有padding填充,因此在這一層的基本的參數是下面這樣的:

    • in_channel: 6
    • out_channel: 16
    • kernel_size: 5
    • stride: 1
    • padding: 0

可是實際上不能只是這樣簡單的定義一個卷積層,由於通常的卷積是在輸入的所有特徵圖上進行卷積操做,可是在這個論文裏的C3層很 「刀劍神域」,他是每個輸出的特徵圖都只挑了輸入的特徵圖裏的一小部分進行卷積操做,具體的映射關係看下面啦:

具體來講,這張圖的含義是這個樣子的:

  • 全部的特徵圖的標號都是從零開始的,輸出特徵圖16個channel,也就是0-15,輸入特徵圖6個channel,也就是0-5

  • 豎着看,0號輸出特徵圖,在0、一、2號輸入特徵圖上打了X,也就是說,0號輸出特徵圖是使用0、一、2號輸入特徵圖,在這個圖像上進行卷積操做

  • 其餘的輸出特徵圖同理

所以咱們須要提早先定義一個用來表示映射關係的表,而後從表裏面挑出來輸入特徵圖進行卷積操做,最後再把獲得的輸出特徵圖拼起來,實際上聽起來和剛剛的Subsampling類的基本邏輯差很少,就是多了一個映射關係而已。因此下面咱們來構造一下這個類吧:

class MapConv(nn.Module):

一樣的,咱們先從構造方法開始一點點的看這個類。

def __init__(self, in_channel, out_channel, kernel_size = 5):
        super(MapConv, self).__init__()
        
        #定義特徵圖的映射方式
        mapInfo = [[1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1],
                   [1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1],
                   [1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1],
                   [0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1],
                   [0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1],
                   [0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1]]
        mapInfo = torch.tensor(mapInfo, dtype = torch.long)
        self.register_buffer("mapInfo", mapInfo) #在Module中的buffer中的參數是不會被求梯度的
        
        self.in_channel = in_channel
        self.out_channel = out_channel
        
        self.convs = {} #將每個定義的卷積層都放進這個字典
        
        #對每個新創建的卷積層都進行註冊,使其真正成爲模塊而且方便調用
        for i in range(self.out_channel):
            conv = nn.Conv2d(mapInfo[:, i].sum().item(), 1, kernel_size)
            convName = "conv{}".format(i)
            self.convs[convName] = conv
            self.add_module(convName, conv)

這個裏面就用到了咱們以前提到的在Module裏面重要的三個字典中的剩下兩個,可能對於萌新小夥伴來講,這段代碼初看起來真的複雜地要死,因此這裏咱們來一點點地解讀這個函數。

首先,對於調用父類進行初始化,而後定義咱們的映射信息這些部分咱們就不看了,沒啥看頭,重點是咱們來看一下下面這一行代碼:

self.register_buffer("mapInfo", mapInfo)

在前面說三大字典的時候咱們提到過,在Module的_buffer中的參數是不會被求導的,能夠當作是常量。可是若是直接定義一個量放在Module裏面的話,他實際上並無被放在_buffer中,所以咱們須要調用從Module類中繼承獲得的register_buffer方法,來將咱們定義的mapInfo強制註冊到_buffer這個字典中。

接下來比較重要的是下面的for循環部分:

for i in range(self.out_channel):
    conv = nn.Conv2d(mapInfo[:, i].sum().item(), 1, kernel_size)
    convName = "conv{}".format(i)
    self.convs[convName] = conv
    self.add_module(convName, conv)

爲何不能像以前那樣一個一個定義卷積層呢?很簡單,由於這裏若是一個一個作的話,要本身定義16個卷積層,並且到寫forward函數中,還要至少寫16次輸出······反正我是寫不來,若是有鐵頭娃想這麼寫的話能夠去試一下,那滋味必定是酸爽得要死┓( ´∀` )┏

首先是關於每個單獨的卷積層的定義部分:

conv = nn.Conv2d(mapInfo[:, i].sum().item(), 1, kernel_size)

前面咱們提到,C3卷積層中的每個特徵圖都是從前面的輸入裏面挑出幾個來作卷積的,而且講那個映射圖的時候說過要一列一列地讀,也就是說卷積層的輸入的通道數in_channels是由mapInfo裏面每一列有幾個 「1」(X)決定的。

接下來是整個循環的剩餘部分:

convName = "conv{}".format(i)
self.convs[convName] = conv
self.add_module(convName, conv)

這部分看起來稍稍有一點複雜,但實際上邏輯仍是蠻簡單的。在咱們自定義的Module的子類中,若是裏面有其餘的Module子類做爲成員(好比Conv2d),那麼框架會將這個子類的實例化對象的對象名做爲key,實際對象做爲value註冊到_module中,可是因爲這裏咱們使用的是循環,因此卷積層的對象名就只有conv一個。

爲了解決這個問題,咱們能夠自行定義一個字典convs,而後將自行定義的convName做爲key,實際對象做爲value,放到這個自定義的字典裏面。可是放到這個字典仍是沒有被註冊進_module裏面,所以咱們須要用從Module類中繼承的add_module()方法,將(convName,conv)做爲鍵值對註冊到字典裏面,這樣咱們才能在forward方法中,直接調用convs字典中的內容用來進行卷積計算。詳細的關於這部分的說明仍是參考一下我在上面提到的兩個博客的連接。

解釋完這個函數以後,接下來是forward函數:

def forward(self, x):
        outs = [] #對每個卷積層經過映射來計算卷積,結果存儲在這裏
        
        for i in range(self.out_channel):
            mapIdx = self.mapInfo[:, i].nonzero().squeeze()
            convInput = x.index_select(1, mapIdx)
            convOutput = self.convs['conv{}'.format(i)](convInput)
            outs.append(convOutput)
        return torch.cat(outs, dim = 1)

咱們仍是直接來看for循環裏面的部分,其實這部分若是是有numpy基礎的人會以爲很簡單,可是畢竟這是面向小白和萌新的博客,因此就稍微聽我囉嗦一下吧。

因爲咱們在看mapInfo的時候是按列看的,也就是說爲了取到每個輸出特徵圖對應的輸入特徵圖,咱們應該把mapInfo每一列的非零元素的下標取出來,也就是mapInfo[:, i].nonzero()。nonzero這個函數的返回值是調用這個函數的tensor裏面的,全部非零元素的下標,而且每個非零點下標自成一維。舉個例子的話,對mapInfo的第0列,調用nonzero的結果應該是:
[[0], [1], [2]],shape:[3, 1]

之因此要在後面加一個squeeze,是由於後續的index_select函數,這個操做要求要求後面對應的下標序列必須是一個一維的,也就是說須要把[[0], [1], [2]]變成[0, 1, 2],從shape:[3, 1]變成shape:[3],所以須要一個squeeze操做進行壓縮。

接下來就是剛剛纔提到的index_select操做,這個函數其實是下面這個樣子:

index_select(dim, index)

還有一些其餘參數就不列出來了,這個函數的功能是,在指定的dim維度上,根據index指定的索引,將對應的全部元素進行一個返回。

對於咱們編寫的函數來講,x的shape是[batch_size, c, h, w],而咱們須要從裏面找到的是從mapInfo中找到的全部非零的channel,也就是說咱們須要指定dim=1,也就是convInput = x.index_select(1, mapIdx)

剩下的內容就和以前介紹的Subsampling的內容差很少了,一樣的對於每一組輸入獲得一組卷積,而後最後把全部卷積結果拼起來。

那如今咱們把這個類的完整的代碼放在一塊兒好啦:

class MapConv(nn.Module):
    def __init__(self, in_channel, out_channel, kernel_size = 5):
        super(MapConv, self).__init__()
        
        #定義特徵圖的映射方式
        mapInfo = [[1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1],
                   [1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1],
                   [1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1],
                   [0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1],
                   [0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1],
                   [0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1]]
        mapInfo = torch.tensor(mapInfo, dtype = torch.long)
        self.register_buffer("mapInfo", mapInfo) #在Module中的buffer中的參數是不會被求梯度的
        
        self.in_channel = in_channel
        self.out_channel = out_channel
        
        self.convs = {} #將每個定義的卷積層都放進這個字典
        
        #對每個新創建的卷積層都進行註冊,使其真正成爲模塊而且方便調用
        for i in range(self.out_channel):
            conv = nn.Conv2d(mapInfo[:, i].sum().item(), 1, kernel_size)
            convName = "conv{}".format(i)
            self.convs[convName] = conv
            self.add_module(convName, conv)
            
    def forward(self, x):
        outs = [] #對每個卷積層經過映射來計算卷積,結果存儲在這裏
        
        for i in range(self.out_channel):
            mapIdx = self.mapInfo[:, i].nonzero().squeeze()
            convInput = x.index_select(1, mapIdx)
            convOutput = self.convs['conv{}'.format(i)](convInput)
            outs.append(convOutput)
        return torch.cat(outs, dim = 1)

考慮到咱們在開頭提到的輸入輸出尺寸以及參數,最後咱們應該作的定義以下所示:

self.C3 = MapConv(6, 16, 5)

和C1同樣,這一層的後面也是沒有激活函數的。

S4層

這個就很簡單啦,就是把咱們以前定義的Subsampling類拿過來用就好了,這裏就說一下輸入輸出的尺寸還有參數好啦:

  • 輸入尺寸:C3的輸出:[16, 10, 10]

  • 輸出尺寸:根據池化的核的大小,尺寸應該爲[16, 5, 5]

  • 參數:從輸入的通道數判斷,in_channel = 16

寫出來的話應該是:

self.S4 = Subsampling(16)

這一層後面有激活函數,出現的問題和S2層同樣,緣由以後說

C5層

這個也好簡單喲啊哈哈哈哈哈,再複雜下去我可能就要被逼瘋了。

和C1層同樣,這裏是一個簡單的卷積層,咱們來分析一下輸入輸出尺寸以及定義參數:

  • 輸入尺寸:[16, 5, 5]

  • 輸出尺寸:[120, 1, 1]

  • 參數:

    • in_channel: 16
    • out_channel: 120
    • kernel_size: 5
    • stride: 1
    • padding: 0

寫出來的話應該是這樣的:

self.C5 = nn.Conv2d(16, 120, 5)

這裏一樣沒有激活函數

F6層

這個也好簡單啊哈哈哈哈哈(喂?120嗎,這裏有個瘋子麻煩大家來處理一下)

這裏是一個簡單的線性全鏈接層,咱們看到上一層的輸入尺寸爲[120, 1, 1],而線性層在不考慮batch_size的時候,要求輸入維度不能這麼多,這就須要用到view函數進行維度的重組,固然啦咱們這一部分能夠放到forward函數裏面,這裏咱們就直接定義一個線性層就好啦:

self.F6 = nn.Linear(120, 84)

這裏有一個激活函數,使用的就是和以前的LeNet-1989同樣的:

\[y=1.7159Tanh({{2} \over {3} }x) \]

Output層

終於要到最後了,個人媽啊,除了本科畢設我仍是頭一第二天常寫東西寫這麼多的,可把我累壞了。原本還想着最後一層能讓人歇歇,結果發現最後一層雖然邏輯很簡單,可是從代碼行數來看真是噁心得一匹,由於裏面涉及到一些數字編碼的問題。總之咱們先往下看一看吧。

這一層的操做也是「刀劍神域」 得不得了,論文在設計這一層的時候實際上至關因而在作一個特徵匹配的工做。論文是將0-9這十個數字的像素編碼提取出來,而後將這個像素編碼展開造成一個向量。在F6層咱們知道輸出的向量的尺寸是[84],這個Output層的任務,就是求解F6層輸出向量,和0-9的每個展開成行向量的像素編碼求一個平方和的距離,保證這個是一個正值。從結果上講,若是這個距離是0,那就說明輸出向量和該數字對應的行向量徹底匹配。距離越小證實越接近,也就是機率越大;距離越大就證實越遠離,也就是機率越低。

可能只是這麼說有一點點不太好理解,咱們來舉個例子說明也許會更容易說明一些。咱們先來看一下 「1」這個數字的編碼大概長什麼樣子。爲了讓你們看得比較清楚,原本應該是黑色是+1,白色是-1,我這邊就寫成黑色是1,白色是0好了。

[0, 0, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0],
[1, 1, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]

仔細看一下里面是 1 的部分,是否是拼起來就像一個印刷體的數字 「1」,固然了你能夠把這個矩陣用opencv、PIL或者matplotlib讀取而後畫出來,確實是印刷體的「1」。而後把它展開造成一個行向量,就是咱們用來進行識別的基準模式了。

而後對於這一層的輸出,使用的距離函數是下面的樣子:

\[y_i=\sum_{j}(x_j-w_{ij})^2 \]

實際就是距離嘛,只不過就是沒有開根號而已。

在這層中須要注意的就只有一個點,那就是數字的行向量組成的這個矩陣是固定死的,也就是說和以前的MapConv層中的mapInfo同樣是不可訓練的常量。

由於這一部分只是代碼比較多而已,可是實際上邏輯上很簡單,因此這裏就不講解了,直接上代碼:

_zero = [-1, +1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, +1, +1, +1, -1, -1] + \
        [-1, +1, +1, -1, +1, +1, -1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [-1, +1, +1, -1, +1, +1, -1] + \
        [-1, -1, +1, +1, +1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1]

_one = [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, +1, +1, +1, -1, -1] + \
       [-1, +1, +1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, +1, +1, +1, +1, +1, +1] + \
       [-1, -1, -1, -1, -1, -1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1]

_two = [-1, +1, +1, +1, +1, +1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1] + \
       [-1, +1, +1, +1, +1, +1, -1] + \
       [+1, +1, -1, -1, -1, +1, +1] + \
       [+1, -1, -1, -1, -1, +1, +1] + \
       [-1, -1, -1, -1, +1, +1, -1] + \
       [-1, -1, +1, +1, +1, -1, -1] + \
       [-1, +1, +1, -1, -1, -1, -1] + \
       [+1, +1, -1, -1, -1, -1, -1] + \
       [+1, +1, +1, +1, +1, +1, +1] + \
       [-1, -1, -1, -1, -1, -1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1]

_three = [+1, +1, +1, +1, +1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, +1, +1, -1] + \
         [-1, -1, -1, +1, +1, -1, -1] + \
         [-1, -1, +1, +1, +1, +1, -1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [-1, +1, +1, +1, +1, +1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1]

_four = [-1, +1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, +1, +1, -1, -1, +1, +1] + \
        [-1, +1, +1, -1, -1, +1, +1] + \
        [+1, +1, +1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, +1, +1, +1] + \
        [-1, +1, +1, +1, +1, +1, +1] + \
        [-1, -1, -1, -1, -1, +1, +1] + \
        [-1, -1, -1, -1, -1, +1, +1]

_five = [-1, +1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [+1, +1, +1, +1, +1, +1, +1] + \
        [+1, +1, -1, -1, -1, -1, -1] + \
        [+1, +1, -1, -1, -1, -1, -1] + \
        [-1, +1, +1, +1, +1, -1, -1] + \
        [-1, -1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [-1, +1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1]

_six = [-1, -1, +1, +1, +1, +1, -1] + \
       [-1, +1, +1, -1, -1, -1, -1] + \
       [+1, +1, -1, -1, -1, -1, -1] + \
       [+1, +1, -1, -1, -1, -1, -1] + \
       [+1, +1, +1, +1, +1, +1, -1] + \
       [+1, +1, +1, -1, -1, +1, +1] + \
       [+1, +1, -1, -1, -1, +1, +1] + \
       [+1, +1, -1, -1, -1, +1, +1] + \
       [+1, +1, +1, -1, -1, +1, +1] + \
       [-1, +1, +1, +1, +1, +1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1]

_seven = [+1, +1, +1, +1, +1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, +1, +1, -1] + \
         [-1, -1, -1, +1, +1, -1, -1] + \
         [-1, -1, -1, +1, +1, -1, -1] + \
         [-1, -1, +1, +1, -1, -1, -1] + \
         [-1, -1, +1, +1, -1, -1, -1] + \
         [-1, -1, +1, +1, -1, -1, -1] + \
         [-1, -1, +1, +1, -1, -1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1]

_eight = [-1, +1, +1, +1, +1, +1, -1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [-1, +1, +1, +1, +1, +1, -1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [-1, +1, +1, +1, +1, +1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1]

_nine = [-1, +1, +1, +1, +1, +1, -1] + \
        [+1, +1, -1, -1, +1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, +1, +1, +1] + \
        [-1, +1, +1, +1, +1, +1, +1] + \
        [-1, -1, -1, -1, -1, +1, +1] + \
        [-1, -1, -1, -1, -1, +1, +1] + \
        [-1, -1, -1, -1, +1, +1, -1] + \
        [-1, +1, +1, +1, +1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1]


RBF_WEIGHT = np.array([_zero, _one, _two, _three, _four, _five, _six, _seven, _eight, _nine]).transpose()

class RBFLayer(nn.Module):
    def __init__(self, in_features, out_features, init_weight = None):
        super(RBFLayer, self).__init__()
        if init_weight is not None:
            self.register_buffer("weight", torch.tensor(init_weight))
        else:
            self.register_buffer("weight", torch.rand(in_features, out_features))
            
    def forward(self, x):
        x = x.unsqueeze(-1)
        x = (x - self.weight).pow(2).sum(-2)
        return x

這樣在定義Output層的時候可使用下面的代碼:

self.Output = RBFLayer(84, 10, RBF_WEIGHT)

到這裏咱們整個神經網絡LeNet-5的基本結構就就定義完畢了,而後初始化部分實際上和LeNet-1989是一致的,在這裏就很少講解了。

損失函數和激活函數

在神經網絡的結構定義完成以後,咱們還須要定義激活函數,要否則咱們的LeNet-5的forward函數沒有辦法寫嘛。前面咱們提到,卷積層都沒有激活函數,池化層都有Sigmoid函數,F6層後面有一個特殊的Tanh函數。這樣作實際上是能夠的,可是我不知道爲啥啊,按照論文這樣去訓練的話,要麼損失函數基本不動,要麼乾脆直接不斷增長而後爆炸······因此這裏爲了契合後面的訓練方法,而且讓咱們的模型能正常訓練出來,這倆咱們前面全部的Sigmoid函數就都用那個特殊的Tanh函數來代替:

self.act = nn.Tanh()

一樣的,咱們將係數放到forward裏面再加,如今先不用管。

這樣咱們的神經網絡的forward函數也能夠寫一下啦:

def forward(self, x):
        x = self.C1(x)
        x = 1.7159 * self.act(2 * self.S2(x) / 3)
        x = self.C3(x)
        x = 1.7159 * self.act(2 * self.S4(x) / 3)
        x = self.C5(x)
        
        x = x.view(-1, 120)
        
        x = 1.7159 * self.act(2 * self.F6(x) / 3)
        
        out = self.Output(x)
        return out

而對於損失函數,論文提到,就是使用Output層的輸出。什麼意思呢?在不考慮batch_size的狀況下,咱們的Output層的輸出應該是一個[10]的向量,每個值就對應着輸入樣本到 0-9 的其中一個數字編碼向量的距離。假設咱們輸入的圖片是0,而後Output層的輸出是[7, 6, 4, 38, 1, 3, 54, 32, 64, 31],那麼對應的損失函數值就是對應的實際類別的距離值,也就是out[0] = 7。

如今咱們結合着這個損失函數以及Output層的計算思路,若是有接觸過一些關於機器學習和深度學習的分類方法的話,應該能判斷出來,這個和在分類問題中的交叉熵損失已經很像了,交叉熵損失的計算其實就是一個log_softmax + NLLoss嘛,雖然在這個LeNet-5中並非用的log_softmax,而是直接用的距離,不過從思路上講,跳出了之前經常使用的位置編碼以及one-hot向量,這一點已是至關超前了啊。

那這樣看的話,實際上損失函數就只是一個簡單的切片索引操做,代碼其實很簡單,就不詳細講了,直接上代碼好啦。

def loss_fn(pred, label):
    if(label.dim() == 1):
        return pred[torch.arange(pred.size(0)), label]
    else:
        return pred[torch.arange(pred.size(0)), label.squeeze()]

如今咱們終於把和網絡結構部分的全部代碼都搞定啦!往回一看發現內容是真的多,這也是爲何這一部分我決定每一部分都把原理和代碼全都放在一塊兒,要是把代碼和原理分開,我估計代碼敲着敲着就不知道該寫哪裏了。這一部分的內容仍是蠻重要的,若是以爲有點混亂的話,最好仍是反覆多看幾遍。下面就是整個神經網絡結構的完整代碼啦:

class LeNet5(nn.Module):
    def __init__(self):
        super(LeNet5, self).__init__()
        self.C1 = nn.Conv2d(1, 6, 5, padding = 2, padding_mode = 'replicate')
        self.S2 = Subsampling(6)
        self.C3 = MapConv(6, 16, 5)
        self.S4 = Subsampling(16)
        self.C5 = nn.Conv2d(16, 120, 5)
        self.F6 = nn.Linear(120, 84)
        self.Output = RBFLayer(84, 10, RBF_WEIGHT)
        
        self.act = 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
            elif 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.C1(x)
        x = 1.7159 * self.act(2 * self.S2(x) / 3)
        x = self.C3(x)
        x = 1.7159 * self.act(2 * self.S4(x) / 3)
        x = self.C5(x)
        
        x = x.view(-1, 120)
        
        x = 1.7159 * self.act(2 * self.F6(x) / 3)
        
        out = self.Output(x)
        return out

訓練函數部分

訓練函數部分和以前的LeNet-1989相比基本沒有什麼變化,因此這裏咱們先把整個代碼放上來:

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)
            y = torch.tensor([label], dtype = torch.long).to(device)
            
            out = model(x)
            optimizer.zero_grad()
            loss = loss_fn(out, y)
            loss.backward()
            optimizer.step()
            
            lossSum += loss.item()
            if (idx + 1) % 2000 == 0: print("sample: {:05d} / {:d} --> loss: {:.4f}".format(idx+1, trainNum, loss.item()))
        
        lossList.append(lossSum / trainNum)
        
        with torch.no_grad():
            errorNum = 0
            for img, label in trainSet:
                x = img.unsqueeze(0).to(device)
                out = model(x)
                _, pred_y = out.min(dim = 1)
                if(pred_y != label): errorNum += 1
            trainError.append(errorNum / trainNum)
            
            errorNum = 0
            for img, label in testSet:
                x = img.unsqueeze(0).to(device)
                out = model(x)
                _, pred_y = out.min(dim = 1)
                if(pred_y != label): errorNum += 1
            testError.append(errorNum / testNum)
        
        if scheduler == True:
            if epoch < 5:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 1.0e-3
            elif epoch < 10:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 5.0e-4
            elif epoch < 15:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 2.0e-4
            else:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 1.0e-4

    torch.save(model.state_dict(), 'F:\\Code_Set\\Python\\PaperExp\\LeNet-5\\epoch-{:d}_loss-{:.6f}_error-{:.2%}.pth'.format(epochs, lossList[-1], testError[-1]))

咱們能夠發現,和上一篇的LeNet-1989的訓練函數比較,就只有一些小地方不太同樣:

  • 因爲論文裏面,訓練集和測試集的錯誤率都被評估了,因此這裏比以前的部分多了一個對訓練集的錯誤率計算;
  • 以前因爲咱們使用的輸出是和one-hot進行比較,所以咱們判斷樣本的對應標籤的時候使用的是max(),可是因爲在這裏咱們是基於向量距離來判斷樣本的標籤,因此實際上在這裏要使用min()來獲取實際標籤

在上一篇中,我並無說這個後面的if判斷裏面的代碼究竟是怎麼回事,因此這裏就來大體講一下:

在訓練神經網絡的時候會用到各類各樣的優化器,而且在初始化優化器的時候咱們經常會使用下面這樣的語句:

optimizer = optim.Adam(model.parameters(), lr=0.001)

實際上當這樣定義以後,框架就會將全部的相關參數,存到優化器內部的一個字典param_group裏面,而且因爲optimizer裏面可能會有不少組參數,因此裏面還有一個更大的字典param_groups,裏面是全部的param_group。這樣當咱們想要調整學習率的時候,就能夠在每一次訓練結束以後,經過遍歷這兩個字典,來將全部的和訓練相關的參數進行自定義的調整。

固然實際上還有一些類能夠專門用於調整學習率,可是在這裏不太好用,因此我就本身寫了一下,你們有興趣能夠去查一下相關的資料,很容易找的。

這裏還有一個和論文不太同樣的地方就是,論文裏面的學習率調整其實蠻複雜的,他大概是這樣調的:

  • 指定一個常數μ=0.02

  • 每個epoch先給一個基準的學習率ε

  • 每通過必定數量的樣本,累計計算二階導數h

  • 每次進行梯度降低的時候使用的實際學習率爲

\[ \epsilon_k={{\epsilon}\over{\mu+h}} \]

就是這麼噁心,因此這裏我就省點事,不計算二階導數了,直接就按照他的ε的降低規律進行訓練好啦(這也有多是我訓練效果不如論文裏面的緣由吧)

那麼如今咱們關於整個模型的構建以及訓練等相關部分就都講完了,接下來,添加一些簡單的可視化工做,而後把代碼所有都拼到一塊兒吧:

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
import numpy as np

'''
定義數據的初始化方法:
    1. 將數據轉化成tensor
    2. 將數據的灰度值範圍從[0, 1]轉化爲[-0.1, 1.175]
    3. 將數據進行尺寸變化的操做咱們放在卷積層的padding操做中,這樣更加方便
'''

picProcessor = T.Compose([
    T.ToTensor(),
    T.Normalize(
        mean = [0.1 / 1.275],
        std = [1.0 / 1.275]
    ),
])

'''
數據的讀取和處理:
    1. 從官網下載太慢了,因此先從新指定路徑,而且在mnist.py文件裏把url改掉
    2. 使用上面的處理器進行MNIST數據的處理,並加載
    3. 將每一張圖片的標籤轉換成one-hot向量
'''
dataPath = "F:\\Code_Set\\Python\\PaperExp\\DataSetForPaper\\" #在使用的時候請改爲本身實際的MNIST數據集路徑
mnistTrain = datasets.MNIST(dataPath, train = True,  download = False, transform = picProcessor)
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. C1(卷積層): in_channel = 1, out_channel = 6, kernel_size = (5, 5), stride = 1, 咱們在這裏將圖片進行padding放大:
        padding = 2, padding_mode = 'replicate', 含義是用複製的方式進行padding
    2. 激活函數: 無
    
    3. S2(下采樣,即池化層):kernel_size = (2, 2), stride = (2, 2), in_channel = 6, 採用平均池化,根據論文,加權平均權重及偏置也可訓練
    4. 激活函數:1.7159Tanh(2/3 * x)
    
    5. C3(卷積層): in_channel = 6, out_channel = 16, kernel_size = (5, 5), stride = 1, padding = 0, 須要注意的是,這個卷積層
        須要使用map進行一個層次的選擇
    6. 激活函數: 無
    
    7. S4(下采樣,即池化層):和S2基本一致,in_channel = 16
    8. 激活函數: 同S2
    
    9. C5(卷積層): in_channel = 16, out_channel = 120, kernel_size = (5, 5), stride = 1, padding = 0
    10. 激活函數: 無
    
    11. F6(全鏈接層): 120 * 84
    12. 激活函數: 同S4
    
    13. output: RBF函數,定義比較複雜,直接看程序
    無激活函數
    
    按照論文的說明,須要對網絡的權重進行一個[-2.4/F_in, 2.4/F_in]的均勻分佈的初始化
    
    因爲池化層和C3卷積層和Pytorch提供的API不同,而且RBF函數以及損失函數Pytorch中並未提供,因此咱們須要繼承nn.Module類自行構造
'''

# 池化層的構造
class Subsampling(nn.Module):
    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)
        
    def forward(self, x):
        x = self.pool(x)
        outs = [] #對每個channel的特徵圖進行池化,結果存儲在這裏
        
        for channel in range(self.in_channel):
            out = x[:, channel] * self.weight[channel] + self.bias[channel] #這一步計算每個channel的池化結果[batch_size, height, weight]
            outs.append(out.unsqueeze(1)) #把channel的維度加進去[batch_size, channel, height, weight]
        return torch.cat(outs, dim = 1)


# C3卷積層的構造
class MapConv(nn.Module):
    def __init__(self, in_channel, out_channel, kernel_size = 5):
        super(MapConv, self).__init__()
        
        #定義特徵圖的映射方式
        mapInfo = [[1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1],
                   [1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1],
                   [1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1],
                   [0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1],
                   [0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1],
                   [0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1]]
        mapInfo = torch.tensor(mapInfo, dtype = torch.long)
        self.register_buffer("mapInfo", mapInfo) #在Module中的buffer中的參數是不會被求梯度的
        
        self.in_channel = in_channel
        self.out_channel = out_channel
        
        self.convs = {} #將每個定義的卷積層都放進這個字典
        
        #對每個新創建的卷積層都進行註冊,使其真正成爲模塊而且方便調用
        for i in range(self.out_channel):
            conv = nn.Conv2d(mapInfo[:, i].sum().item(), 1, kernel_size)
            convName = "conv{}".format(i)
            self.convs[convName] = conv
            self.add_module(convName, conv)
            
    def forward(self, x):
        outs = [] #對每個卷積層經過映射來計算卷積,結果存儲在這裏
        
        for i in range(self.out_channel):
            mapIdx = self.mapInfo[:, i].nonzero().squeeze()
            convInput = x.index_select(1, mapIdx)
            convOutput = self.convs['conv{}'.format(i)](convInput)
            outs.append(convOutput)
        return torch.cat(outs, dim = 1)
    

# RBF函數output層的構建
class RBFLayer(nn.Module):
    def __init__(self, in_features, out_features, init_weight = None):
        super(RBFLayer, self).__init__()
        if init_weight is not None:
            self.register_buffer("weight", torch.tensor(init_weight))
        else:
            self.register_buffer("weight", torch.rand(in_features, out_features))
            
    def forward(self, x):
        x = x.unsqueeze(-1)
        x = (x - self.weight).pow(2).sum(-2)
        return x
 
 
# 損失函數的構建
def loss_fn(pred, label):
    if(label.dim() == 1):
        return pred[torch.arange(pred.size(0)), label]
    else:
        return pred[torch.arange(pred.size(0)), label.squeeze()]

    
# RBF的初始化權重
_zero = [-1, +1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, +1, +1, +1, -1, -1] + \
        [-1, +1, +1, -1, +1, +1, -1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [-1, +1, +1, -1, +1, +1, -1] + \
        [-1, -1, +1, +1, +1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1]

_one = [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, +1, +1, +1, -1, -1] + \
       [-1, +1, +1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, -1, -1, +1, +1, -1, -1] + \
       [-1, +1, +1, +1, +1, +1, +1] + \
       [-1, -1, -1, -1, -1, -1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1]

_two = [-1, +1, +1, +1, +1, +1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1] + \
       [-1, +1, +1, +1, +1, +1, -1] + \
       [+1, +1, -1, -1, -1, +1, +1] + \
       [+1, -1, -1, -1, -1, +1, +1] + \
       [-1, -1, -1, -1, +1, +1, -1] + \
       [-1, -1, +1, +1, +1, -1, -1] + \
       [-1, +1, +1, -1, -1, -1, -1] + \
       [+1, +1, -1, -1, -1, -1, -1] + \
       [+1, +1, +1, +1, +1, +1, +1] + \
       [-1, -1, -1, -1, -1, -1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1]

_three = [+1, +1, +1, +1, +1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, +1, +1, -1] + \
         [-1, -1, -1, +1, +1, -1, -1] + \
         [-1, -1, +1, +1, +1, +1, -1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [-1, +1, +1, +1, +1, +1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1]

_four = [-1, +1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, +1, +1, -1, -1, +1, +1] + \
        [-1, +1, +1, -1, -1, +1, +1] + \
        [+1, +1, +1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, +1, +1, +1] + \
        [-1, +1, +1, +1, +1, +1, +1] + \
        [-1, -1, -1, -1, -1, +1, +1] + \
        [-1, -1, -1, -1, -1, +1, +1]

_five = [-1, +1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [+1, +1, +1, +1, +1, +1, +1] + \
        [+1, +1, -1, -1, -1, -1, -1] + \
        [+1, +1, -1, -1, -1, -1, -1] + \
        [-1, +1, +1, +1, +1, -1, -1] + \
        [-1, -1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [-1, +1, +1, +1, +1, +1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1]

_six = [-1, -1, +1, +1, +1, +1, -1] + \
       [-1, +1, +1, -1, -1, -1, -1] + \
       [+1, +1, -1, -1, -1, -1, -1] + \
       [+1, +1, -1, -1, -1, -1, -1] + \
       [+1, +1, +1, +1, +1, +1, -1] + \
       [+1, +1, +1, -1, -1, +1, +1] + \
       [+1, +1, -1, -1, -1, +1, +1] + \
       [+1, +1, -1, -1, -1, +1, +1] + \
       [+1, +1, +1, -1, -1, +1, +1] + \
       [-1, +1, +1, +1, +1, +1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1] + \
       [-1, -1, -1, -1, -1, -1, -1]

_seven = [+1, +1, +1, +1, +1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, -1, +1, +1] + \
         [-1, -1, -1, -1, +1, +1, -1] + \
         [-1, -1, -1, +1, +1, -1, -1] + \
         [-1, -1, -1, +1, +1, -1, -1] + \
         [-1, -1, +1, +1, -1, -1, -1] + \
         [-1, -1, +1, +1, -1, -1, -1] + \
         [-1, -1, +1, +1, -1, -1, -1] + \
         [-1, -1, +1, +1, -1, -1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1]

_eight = [-1, +1, +1, +1, +1, +1, -1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [-1, +1, +1, +1, +1, +1, -1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [+1, +1, -1, -1, -1, +1, +1] + \
         [-1, +1, +1, +1, +1, +1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1] + \
         [-1, -1, -1, -1, -1, -1, -1]

_nine = [-1, +1, +1, +1, +1, +1, -1] + \
        [+1, +1, -1, -1, +1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, -1, +1, +1] + \
        [+1, +1, -1, -1, +1, +1, +1] + \
        [-1, +1, +1, +1, +1, +1, +1] + \
        [-1, -1, -1, -1, -1, +1, +1] + \
        [-1, -1, -1, -1, -1, +1, +1] + \
        [-1, -1, -1, -1, +1, +1, -1] + \
        [-1, +1, +1, +1, +1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1] + \
        [-1, -1, -1, -1, -1, -1, -1]


RBF_WEIGHT = np.array([_zero, _one, _two, _three, _four, _five, _six, _seven, _eight, _nine]).transpose()

#整個神經網絡的搭建
class LeNet5(nn.Module):
    def __init__(self):
        super(LeNet5, self).__init__()
        self.C1 = nn.Conv2d(1, 6, 5, padding = 2, padding_mode = 'replicate')
        self.S2 = Subsampling(6)
        self.C3 = MapConv(6, 16, 5)
        self.S4 = Subsampling(16)
        self.C5 = nn.Conv2d(16, 120, 5)
        self.F6 = nn.Linear(120, 84)
        self.Output = RBFLayer(84, 10, RBF_WEIGHT)
        
        self.act = 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
            elif 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.C1(x)
        x = 1.7159 * self.act(2 * self.S2(x) / 3)
        x = self.C3(x)
        x = 1.7159 * self.act(2 * self.S4(x) / 3)
        x = self.C5(x)
        
        x = x.view(-1, 120)
        
        x = 1.7159 * self.act(2 * self.F6(x) / 3)
        
        out = self.Output(x)
        return out
        
lossList = []
trainError = []
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)
            y = torch.tensor([label], dtype = torch.long).to(device)
            
            out = model(x)
            optimizer.zero_grad()
            loss = loss_fn(out, y)
            loss.backward()
            optimizer.step()
            
            lossSum += loss.item()
            if (idx + 1) % 2000 == 0: print("sample: {:05d} / {:d} --> loss: {:.4f}".format(idx+1, trainNum, loss.item()))
        
        lossList.append(lossSum / trainNum)
        
        with torch.no_grad():
            errorNum = 0
            for img, label in trainSet:
                x = img.unsqueeze(0).to(device)
                out = model(x)
                _, pred_y = out.min(dim = 1)
                if(pred_y != label): errorNum += 1
            trainError.append(errorNum / trainNum)
            
            errorNum = 0
            for img, label in testSet:
                x = img.unsqueeze(0).to(device)
                out = model(x)
                _, pred_y = out.min(dim = 1)
                if(pred_y != label): errorNum += 1
            testError.append(errorNum / testNum)
        
        if scheduler == True:
            if epoch < 5:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 1.0e-3
            elif epoch < 10:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 5.0e-4
            elif epoch < 15:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 2.0e-4
            else:
                for param_group in optimizer.param_groups:
                    param_group['lr'] = 1.0e-4

    torch.save(model.state_dict(), 'F:\\Code_Set\\Python\\PaperExp\\LeNet-5\\epoch-{:d}_loss-{:.6f}_error-{:.2%}.pth'.format(epochs, lossList[-1], testError[-1]))
            

if __name__ == '__main__':

    model = LeNet5().to(device)
    optimizer = optim.SGD(model.parameters(), lr = 1.0e-3)
    
    scheduler = True
    
    epochs = 25
    
    train(epochs, model, optimizer, scheduler, loss_fn, mnistTrain, mnistTest)
    plt.subplot(1, 3, 1)
    plt.plot(lossList)
    plt.subplot(1, 3, 2)
    plt.plot(trainError)
    plt.subplot(1, 3 ,3)
    plt.plot(testError)
    plt.show()

結果簡要分析

我寫到這裏真的有點頭痛了······因此我就偷個懶,結果的曲線就不畫了,直接說一個結果好了。

最終這個模型訓練,在訓練25輪以後,損失函數值爲5.0465(論文並未說起),在訓練集的錯誤率爲1.2%(論文爲0.35%),在測試集上的錯誤率爲2.1%(論文爲0.95%),不過考慮到他的學習率的降低方式並未被完美還原,再加上訓練25輪的時候模型其實還有收斂的空間,只不過由於當時忽然有點別的工做因此電腦要騰出來幹別的沒得辦法繼續訓練了,因此其實模型的性能還能再經過訓練提升。不過就復現工做自己,我以爲已經差很少能夠了吧,嗯,就當時這樣好了┓( ´∀` )┏

結果反思

整體來講這篇論文不少地方很是值得學習,主要存在如下幾個方面吧:

  • 卷積結構的提出,這個雖然是廢話,可是這也告訴咱們一個事情,就是神經網絡的結構自己還有不少不合理的地方,有待咱們繼續優化,就好比說卷積結構相較於以前的全鏈接結構,引入了平移不變性,可是卷積自己仍然不支持旋轉不變性,這也是爲何後來的基於卷積的模型,大多數都對訓練集進行了隨機旋轉、鏡像變換等數據加強
  • 引入了池化概念,雖然在論文中的池化方法並非很合適,可是這種但願經過模糊化從而將特徵的位置信息進行剔除的思想仍是十分先進的
  • 設計了近似於dropout的卷積層,雖然稍微有一點點那種畫蛇添足的感受,可是這其實結合如今的一些研究看來,仍是有一些繼續思考的空間的。好比卷積層的輸出通道數out_channels一直是一個不太好選擇的超參數,而且在訓練中觀察卷積的參數變化,其實能夠發現卷積層中有許多特徵圖都是冗餘沒有必要的。其實在這篇LeNet-5的論文中就蘊含着一種思想,就是既然有冗餘特徵,那我乾脆強制讓特徵圖學一些特徵,這樣不就不冗餘了嘛,並且能夠順便把參數量降下來。其實從優化角度上講,這種想法仍是蠻有趣的
  • 引入了和交叉熵十分接近的損失函數,這就使得進行分類問題的處理的時候,整個模型的性能以及可解釋性變得更好了。
  • 引入了降低的學習率,對於訓練來講,當訓練到接近局部最優值的時候,若是學習率還很大那確定是容易在最優值附近不停地震盪,而後致使模型最終性能不是很好(祕技 · 反覆橫跳!)。此時下降學習率,將會更容易收斂到最優值附近。
  • 引入了隨機梯度降低

先不說上面的理論上理解了多少,反正在復現這篇論文的時候,最起碼python以及pytorch的編程能力上確定是提升了很多,並且pytorch的源碼閱讀經驗也確定是積累了很多┓( ´∀` )┏

整篇論文確實有很多閃光點,可是裏面仍是有一些不是很好的地方,固然啦這篇文章在當時來看已是至關優秀了,只是在技術不斷髮展的如今,再回過頭去看這篇文章,多少仍是有一點點論文已經落伍了的感受,而且有一些地方在我我的淺薄的知識量來看,感受有一點不太對勁:

  • 平均池化:論文提到,爲了可以使特徵的位置參數模糊化,可使用平均池化進行處理,乍一看感受好像挺對的,可是接下來咱們來舉一個例子進行簡單說明,假設平均池化就是作個平均值,也不作其餘的任何處理了。如今咱們假設如今有一個輸入向量[48, 0, 0, 0],數值超過24的時候咱們認爲是某一特徵存在,顯然從輸入來看特徵存在,若是是最大池化的話,池化結果爲48,特徵存在;可是平均池化最終的結果是12,特徵不存在。再假設有另外一個輸入向量[25, 25, 25, 25],最大池化爲25,特徵存在;平均池化結果爲25,特徵存在。舉這個例子的目的就在於,對於識別問題來講,爲了保證不變性,最大池化我感受效果應該是很好的。這裏論文多是考慮過這個問題因此引入了學習參數,使得特徵可以經過學習來獲得,可是這個我感受稍微有一種畫蛇添足的感受。(固然啦也有多是我讀論文太少,沒有看到關於最大池化的缺點的相關論文,若是有大佬在評論區指點一下的話那就更好了)
  • dropout的卷積:單純從減小參數量的角度上講,這樣的設計可能仍是挺不錯的,若是是想實現一個近似的dropout的功能的話,我以爲其實有一點點不必,畢竟若是輸出特徵圖在全部的輸入特徵圖上作卷積的話,輸入特徵圖若是不是很重要那麼學習到的權重會近似爲0,也就達到和這我的爲的dropout差很少的效果了。並且因爲這個是人工設計的映射模式,那麼這個模式究竟是否合適?這至關於又引入了新的超參數和人工特徵,對於模型的泛化來講可能並非那麼有幫助。不過從卷積結構出發的特殊處理這種思路我以爲仍是值得借鑑的。
  • RBF層的處理:前面論文裏面提到了卷積以及池化,目的實際就是在於讓整個網絡對於特徵的位置信息變得不那麼敏感,可是在這一層,又引入了與位置信息強相關的數字編碼向量,並且是用輸入和這個向量進行距離計算獲得分類依據。我以爲這樣又把以前好不容易丟掉了的位置信息又撿回來了,固然也有多是我沒能正確理解這一部分的含義,反正我是以爲這裏怪怪的。而且,若是是以距離爲依據來判斷分類依據的話,我以爲不能簡單地只是求一個距離,而是應該對輸入和數字編碼向量先都進行單位化,這樣才能將由於向量的模長致使的距離變化的影響降到最低,事實上若是是判斷距離的話,我以爲角度是最重要的,而具體的距離長度反而不是那麼重要,畢竟這些數字編碼向量確定是線性無關的嘛。
  • 隨機梯度降低:在前一篇文章中只是簡單提了一下,這裏稍微細緻介紹一下。對於通常的正常的基於全部樣本的梯度降低來講,使用的公式大概是這樣的:

\[grad \ f={{1}\over{N}}\sum f' \]

這樣作是最準確的,可是會致使每一次都要計算不少的導數進行相加,並且這麼大的計算量還只是計算了更新參數的一小步,每一小步都要計算這麼多東西,很明顯計算成本是很高的。

仔細觀察一下上面的式子,能夠發現若是把整個訓練集看作一個整體空間,那麼取平均至關因而求一個指望,從咱們學習過的機率論裏面的東西能夠知道,若是咱們從中取出一部分樣本,對樣本求均值,那這個均值確定是實際指望的無偏估計量,而這個實際上就是mini-batch的梯度降低的思想。若是咱們進一步極端化,徹底能夠取其中的一個樣本值做爲指望來代替,這也就是SGD隨機梯度降低。可是你們思考一下,隨機梯度降低一次選一個樣本是很快,可是這個樣本值和實際的指望之間的差距太過於隨機了,致使模型極可能朝着徹底不合適的方向進行優化,因此合理的使用mini-batch我以爲纔是最好的。

從這個描述上也能夠看出來,爲了引入隨機性而且不讓計算量太大,batch_size也不能取得太大。

結語

總之從學習來講,這篇論文比較適合拿來作一個對比思考,以及用來熟悉Pytorch的各類操做。反正我是以爲看完這篇論文以後,基本的一些深度學習的概念,以及Pytorch各類模塊與類的使用,好多都涉及到了,並且掌握地也稍微比之前好了那麼一點點。

固然啦,我也算是一個剛剛入行的小白,可能仍是不少的內容說得並不正確,仍是但願各位大佬能在評論區批評指正。下一篇打算是復現一下AlexNet,可是ImageNet的數據集一來我不知道從哪裏找(官網下載有點慢,若是誰有網盤連接但願能給我一份,不勝感激),二來是這個數據集太大了,130+G,我假期回家之後就家裏的破電腦實在是無能爲力,因此下一篇重點就放在模型怎麼構建上,就不訓練了哈,你們感興趣能夠本身搞一搞,我就不弄了。

寫了這麼多可算是要結束了,忽然有點不捨得呢(T_T),若是不出意外的話應該就是更新AlexNet了,那你們下次再見辣!(一看字數快2w,頭一次寫這麼多我真是要吐了)

參考內容:

  1. LeNet-5論文
  2. 博客:https://blog.csdn.net/u012436149/article/details/78281553
  3. 博客:http://www.javashuo.com/article/p-gfbtymgr-hw.html
  4. 博客:http://li-shan.cn/2020/09/08/LeNet/
  5. pytorch官方文檔
相關文章
相關標籤/搜索