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

JacobDale發表於2021-01-22

小白的經典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都能執行,只需要把檔案路徑改成對應作業系統的格式就行

在開始寫程式碼之前,同樣的,把我們需要的模組啥的,一股腦先都裝進來,免得後面有什麼東西給忘記了:

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

https://www.cnblogs.com/zhangxiann/p/13579624.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、1、2號輸入特徵圖上打了X,也就是說,0號輸出特徵圖是使用0、1、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. 部落格:https://www.cnblogs.com/zhangxiann/p/13579624.html
  4. 部落格:http://li-shan.cn/2020/09/08/LeNet/
  5. pytorch官方文件

相關文章