一、介紹
實驗內容
內容包括用 PyTorch 來實現一個卷積神經網絡,從而實現手寫數字識別任務。
除此之外,還對卷積神經網絡的卷積核、特征圖等進行了分析,引出了過濾器的概念,並簡單示了卷積神經網絡的工作原理。
知識點
- 使用 PyTorch 數據集三件套的方法
- 卷積神經網絡的搭建與訓練
- 可視化卷積核、特征圖的方法
二、數據准備
引入相關包
import torch
import torch.nn as nn
from torch.autograd import Variable
import torch.optim as optim
import torch.nn.functional as F
import torchvision.datasets as dsets
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
import numpy as np
%matplotlib inline
定義超參數
# 定義超參數
image_size = 28 #圖像的總尺寸28*28
num_classes = 10 #標簽的種類數
num_epochs = 20 #訓練的總循環周期
batch_size = 64 #一個撮(批次)的大小,64張圖片
使用 PyTorch 數據加載三套件
PyTorch 自帶的數據加載器,包括 dataset
,sampler
,以及 data loader
這三個對象組成的套件。
為什么要使用 PyTorch 自帶的數據加載器?
當數據集很小,格式比較規則的時候,數據加載三套件的優勢並不明顯。
但是當數據格式比較特殊,以及數據規模很大(內存無法同時加載所有數據)的時候,三套件的威力就會顯現出來了。
特別是,當需要用不同的處理器來並行加載數據的時候,PyTorch 數據加載器還可以自動進行數據的分布式加載。
下載數據 網盤鏈接:https://pan.baidu.com/s/1uGoWcXBm2Ubf647YKj1PzA 提取碼:g416
本次使用 dataset
來裝載數據集,使用 sampler
采樣數據集,使用 data_loader
完成數據集的迭代和循環。
下面首先創建 2 個 dataset
,分別用來裝載訓練數據集和測試數據集。
# 加載 MNIST 數據,如果沒有下載過,就會在當前路徑下新建 /data 子目錄,並把文件存放其中
# MNIST 數據是屬於 torchvision 包自帶的數據,所以可以直接調用。
train_dataset = dsets.MNIST(root='./data', #文件存放路徑
train=True, #提取訓練集
#將圖像轉化為 Tensor,在加載數據的時候,就可以對圖像做預處理
transform=transforms.ToTensor(),
download=True) #當找不到文件的時候,自動下載
# 加載測試數據集
test_dataset = dsets.MNIST(root='./data',
train=False,
transform=transforms.ToTensor())
# 如果想要調用非 PyTorch 的自帶數據,比如自己准備的數據集,
# 可以用 torchvision.datasets.ImageFolder 或者 torch.utils.data.TensorDataset 來加載
data_loader
用於完成數據集的迭代和循環,也稱為數據加載器。
下面將為訓練數據集創建一個數據加載器。
# 訓練數據集的加載器,自動將數據分割成batch,順序隨機打亂
train_loader = torch.utils.data.DataLoader(dataset=train_dataset,
batch_size=batch_size,
shuffle=True)
下面將測試數據分成兩部分,一部分作為校驗數據,一部分作為測試數據。
校驗數據用於檢測模型是否過擬合,並調整參數,測試數據檢驗整個模型的工作。
# 首先創建 test_dataset 中所有數據的索引下標
indices = range(len(test_dataset))
# 利用數據下標,將 test_dataset 中的前 5000 條數據作為 校驗數據
indices_val = indices[:5000]
# 剩下的就作為測試數據了
indices_test = indices[5000:]
采樣器(sampler)為加載器(data_loader)提供了一個從每一批數據中抽取樣本的方法。
不同的采樣器有不同的采樣方法,有些采樣器可以隨機采樣,有些可以根據權重進行采樣,甚至還有些可以根據某種概率分布從數據集中進行采樣。
# 根據分好的下標,構造兩個數據集的 SubsetRandomSampler 采樣器,它會對下標進行采樣
sampler_val = torch.utils.data.sampler.SubsetRandomSampler(indices_val)
sampler_test = torch.utils.data.sampler.SubsetRandomSampler(indices_test)
# 校驗數據集的加載器
validation_loader = torch.utils.data.DataLoader(dataset =test_dataset,
batch_size = batch_size,
sampler = sampler_val
)
# 驗證數據集的加載器
test_loader = torch.utils.data.DataLoader(dataset=test_dataset,
batch_size=batch_size,
sampler = sampler_test
)
通過 Matplotlib 將數據集中的手寫數字繪制出來
# 隨便指定一個數據下標
idx = 100
# dataset 支持下標索引
# 其中提取出來的每一個元素為 features,target格式,即屬性和標簽
# [0] 表示索引 features
muteimg = train_dataset[idx][0].numpy()
# 由於一般的圖像包含rgb三個通道,而MINST數據集的圖像都是灰度的,只有一個通道
# 因此,我們忽略通道,把圖像看作一個灰度矩陣
plt.imshow(muteimg[0,...], cmap ='gray')
print('標簽是:',train_dataset[idx][1])
基本的卷積神經網絡
構建網絡
下面將要調用 PyTorch 強大的 nn.Module
這個類來構建卷積神經網絡。步驟如下:
- 1.首先構造 ConvNet 類,它是對類 nn.Module 的繼承。
- 2.重寫父類的
init
以及forward
這兩個函數。 - 3.在 ConvNet 類中編寫自定義的方法。
在第二步中重寫的 init
為構造函數,每當類 ConvNet
被具體化一個實例的時候,就會被調用。
forward
函數則是在運行神經網絡正向的時候會被自動調用。
# 定義卷積神經網絡:4 和 8 為人為指定的兩個卷積層的厚度(feature map的數量)
depth = [4, 8]
class ConvNet(nn.Module):
def __init__(self):
# 該函數在創建一個 ConvNet 對象的時候,即調用如下語句:net=ConvNet(),就會被調用
# 首先調用父類相應的構造函數
super(ConvNet, self).__init__()
# 其次構造ConvNet需要用到的各個神經模塊。
'''注意,定義組件並沒有真正搭建這些組件,只是把基本建築磚塊先找好'''
self.conv1 = nn.Conv2d(1, 4, 5, padding = 2) #定義一個卷積層,輸入通道為1,輸出通道為4,窗口大小為5,padding為2
self.pool = nn.MaxPool2d(2, 2) #定義一個Pooling層,一個窗口為2*2的pooling運算
self.conv2 = nn.Conv2d(depth[0], depth[1], 5, padding = 2) #第二層卷積,輸入通道為depth[0],
#輸出通道為depth[1],窗口為5,padding為2
self.fc1 = nn.Linear(image_size // 4 * image_size // 4 * depth[1] , 512)
#一個線性連接層,輸入尺寸為最后一層立方體的平鋪,輸出層512個節點
self.fc2 = nn.Linear(512, num_classes) #最后一層線性分類單元,輸入為512,輸出為要做分類的類別數
def forward(self, x):
#該函數完成神經網絡真正的前向運算,我們會在這里把各個組件進行實際的拼裝
#x的尺寸:(batch_size, image_channels, image_width, image_height)
x = F.relu(self.conv1(x)) #第一層卷積,激活函數用ReLu,為了防止過擬合
#x的尺寸:(batch_size, num_filters, image_width, image_height)
x = self.pool(x) #第二層pooling,將圖片變小
#x的尺寸:(batch_size, depth[0], image_width/2, image_height/2)
x = F.relu(self.conv2(x)) #第三層又是卷積,窗口為5,輸入輸出通道分別為depth[0]=4, depth[1]=8
#x的尺寸:(batch_size, depth[1], image_width/2, image_height/2)
x = self.pool(x) #第四層pooling,將圖片縮小到原大小的1/4
#x的尺寸:(batch_size, depth[1], image_width/4, image_height/4)
# 將立體的特征圖Tensor,壓成一個一維的向量
# view這個函數可以將一個tensor按指定的方式重新排布。
# 下面這個命令就是要讓x按照batch_size * (image_size//4)^2*depth[1]的方式來排布向量
x = x.view(-1, image_size // 4 * image_size // 4 * depth[1])
#x的尺寸:(batch_size, depth[1]*image_width/4*image_height/4)
x = F.relu(self.fc1(x)) #第五層為全鏈接,ReLu激活函數
#x的尺寸:(batch_size, 512)
x = F.dropout(x, training=self.training) #以默認為0.5的概率對這一層進行dropout操作,為了防止過擬合
x = self.fc2(x) #全鏈接
#x的尺寸:(batch_size, num_classes)
#輸出層為log_softmax,即概率對數值log(p(x))。采用log_softmax可以使得后面的交叉熵計算更快
x = F.log_softmax(x, dim = 1)
return x
def retrieve_features(self, x):
#該函數專門用於提取卷積神經網絡的特征圖的功能,返回feature_map1, feature_map2為前兩層卷積層的特征圖
feature_map1 = F.relu(self.conv1(x)) #完成第一層卷積
x = self.pool(feature_map1) # 完成第一層pooling
feature_map2 = F.relu(self.conv2(x)) #第二層卷積,兩層特征圖都存儲到了feature_map1, feature_map2中
return (feature_map1, feature_map2)
訓練卷積神經網絡
首先編寫一個計算預測錯誤率的函數,其中 predictions
是模型給出的一組預測結果,batch_size
行 num_classes
列的矩陣,labels
是數據中的正確答案。
def rightness(predictions, labels):
# 對於任意一行(一個樣本)的輸出值的第1個維度,求最大,得到每一行的最大元素的下標
pred = torch.max(predictions.data, 1)[1]
# 將下標與labels中包含的類別進行比較,並累計得到比較正確的數量
rights = pred.eq(labels.data.view_as(pred)).sum()
# 返回正確的數量和這一次一共比較了多少元素
return rights, len(labels)
首先實例化模型,定義損失函數和優化器。
net = ConvNet() #新建一個卷積神經網絡的實例,此時ConvNet的__init__函數就會被自動調用
criterion = nn.CrossEntropyLoss() #Loss函數的定義,交叉熵
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9) #定義優化器,普通的隨機梯度下降算法
把訓練模型和驗證模型的語句封裝成函數
# 參數:
# data : torch.Variable
# target: torch.Variable
def train_model(data, target):
# 給網絡模型做標記,標志說模型正在訓練集上訓練
# 這種區分主要是為了打開 net 的 training 標志
# 從而決定是否運行 dropout 與 batchNorm
net.train()
output = net(data) #神經網絡完成一次前饋的計算過程,得到預測輸出output
loss = criterion(output, target) #將output與標簽target比較,計算誤差
optimizer.zero_grad() #清空梯度
loss.backward() #反向傳播
optimizer.step() #一步隨機梯度下降算法
right = rightness(output, target) #計算准確率所需數值,返回數值為(正確樣例數,總樣本數)
return right, loss
調用了 net.eval()
,將模型設置為 Evaluation Mode
,即驗證模式。
# Evaluation Mode
def evaluation_model():
# net.eval() 給網絡模型做標記,標志說模型現在是驗證模式
# 此方法將模型 net 的 training 標志設置為 False
# 模型中將不會運行 dropout 與 batchNorm
net.eval()
#記錄校驗數據集准確率的容器
val_rights = []
'''開始在校驗數據集上做循環,計算校驗集上面的准確度'''
for (data, target) in validation_loader:
data, target = Variable(data), Variable(target)
# 完成一次模型的 forward 計算過程,得到模型預測的分類概率
output = net(data)
# 統計正確數據次數,得到:(正確樣例數,batch總樣本數)
right = rightness(output, target)
# 加入到容器中,以供后面計算正確率使用
val_rights.append(right)
return val_rights
正式開始訓練流程
record = [] #記錄准確率等數值的容器
weights = [] #每若干步就記錄一次卷積核
#開始訓練循環
for epoch in range(num_epochs):
train_rights = [] #記錄訓練數據集准確率的容器
'''
下面的enumerate是構造一個枚舉器的作用。就是在對train_loader做循環迭代的時候,enumerate會自動吐出一個數字指示循環了幾次
這個數字就被記錄在了batch_idx之中,它就等於0,1,2,……
train_loader每迭代一次,就會吐出來一對數據data和target,分別對應着一個batch中的手寫數字圖,以及對應的標簽。
'''
for batch_idx, (data, target) in enumerate(train_loader): #針對容器中的每一個批進行循環
# 將 Tensor 轉化為 Variable,data 為一批圖像,target 為一批標簽
data, target = Variable(data), Variable(target)
# 調用模型訓練函數
right, loss = train_model(data, target)
#將計算結果裝到列表容器train_rights中
train_rights.append(right)
if batch_idx % 100 == 0: #每間隔100個batch執行一次打印等操作
# 調用模型驗證函數
val_rights = evaluation_model()
# 統計驗證模型時的正確率
# val_r為一個二元組,分別記錄校驗集中分類正確的數量和該集合中總的樣本數
val_r = (sum([tup[0] for tup in val_rights]), sum([tup[1] for tup in val_rights]))
# 統計上面訓練模型時的正確率
# train_r為一個二元組,分別記錄目前已經經歷過的所有訓練集中分類正確的數量和該集合中總的樣本數,
train_r = (sum([tup[0] for tup in train_rights]), sum([tup[1] for tup in train_rights]))
# 計算並打印出模型在訓練時和在驗證時的准確率
# train_r[0]/train_r[1]就是訓練集的分類准確度,同樣,val_r[0]/val_r[1]就是校驗集上的分類准確度
print('訓練周期: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}\t訓練正確率: {:.2f}%\t校驗正確率: {:.2f}%'.format(
epoch, batch_idx * batch_size, len(train_loader.dataset),
100. * batch_idx / len(train_loader), loss.data,
100. * train_r[0].numpy() / train_r[1],
100. * val_r[0].numpy() / val_r[1]))
# 將准確率和權重等數值加載到容器中,以方便后面將模型訓練曲線繪制出來
record.append((100 - 100. * train_r[0] / train_r[1], 100 - 100. * val_r[0] / val_r[1]))
# 在這里將模型中的權重參數保存起來,以供后面解剖分析神經網絡時使用
# weights 錄了訓練周期中所有卷積核的演化過程,net.conv1.weight就提取出了第一層卷積核的權重
# clone的意思就是將 weight.data 中的數據做一個拷貝放到列表中,
# 否則當 weight.data 變化的時候,列表中的每一項數值也會聯動
'''這里使用clone這個函數很重要'''
weights.append([net.conv1.weight.data.clone(), net.conv1.bias.data.clone(),
net.conv2.weight.data.clone(), net.conv2.bias.data.clone()])
關注兩個小的細節:
一個是出現在訓練數據訓話中的 net.train()
,另一個是出現在校驗數據循環中的 net.eval()
,它們是做什么的?
net.train()
會將模型設置為訓練模式(training mode),即開啟 Dropout。
net.eval()
會將模型設置為驗證模式(evaluation mode),會關閉 Dropout。
觀察並驗證模型訓練效果
將訓練過程中的誤差曲線打印出來,以觀察訓練的過程。
#繪制訓練過程的誤差曲線,校驗集和測試集上的錯誤率。
plt.figure(figsize = (10, 7))
plt.plot(record) #record記載了每一個打印周期記錄的訓練和校驗數據集上的准確度
plt.xlabel('Steps')
plt.ylabel('Error rate')
將訓練過的模型在測試集上做測驗
#在測試集上分批運行,並計算總的正確率
net.eval() #標志模型當前為運行階段
vals = [] #記錄准確率所用列表
#對測試數據集進行循環
for data, target in test_loader:
data, target = Variable(data, requires_grad=True), Variable(target)
output = net(data) #將特征數據喂入網絡,得到分類的輸出
val = rightness(output, target) #獲得正確樣本數以及總樣本數
vals.append(val) #記錄結果
#計算准確率
rights = (sum([tup[0] for tup in vals]), sum([tup[1] for tup in vals]))
right_rate = 1.0 * rights[0].data.numpy() / rights[1]
right_rate
隨便從測試集中讀入一張圖片,檢驗模型的分類結果,並繪制出來。
idx = 4
muteimg = test_dataset[idx][0].numpy()
plt.imshow(muteimg[0,...], cmap='gray')
print('正確標簽是:', test_dataset[idx][1])
# 使用torch.view()將輸入變換形狀,神經網絡的輸入為:(batch, 1, 28, 28)
# 測試的時候只有一個數據,所以 batch 為 1
test_input = torch.Tensor(muteimg).view(1, 1, 28, 28)
out = net(Variable(test_input))
print('模型預測結果是:', torch.max(out, 1)[1].data.numpy())
四、解剖卷積神經網絡
第一層卷積核訓練得到了什么
首先打印出搭建好的卷積神經網絡 ConvNet 的組成元素(注意不是結構),以方便我們取得不同結構的相應參數。
net.parameters
觀察第一層卷積核所對應的 4 個特征圖(feature map)
由於第二層卷積層的輸入的尺寸是 (28,28,4),所以每個卷積核是一個 (5,5,4)的張量,所以下面每個卷積核都會繪制出 4 行。
idx = 4
# 首先定義讀入的圖片
# 它是從 test_dataset 中提取第 idx 個批次的第 0 個圖,其次 unsqueeze 的作用是在最前面添加一維
# 目的是為了讓這個 input_x 的 tensor 是四維的,這樣才能輸入給 net,補充的那一維表示 batch
input_x = test_dataset[idx][0].unsqueeze(0)
# 調用 net 的 retrieve_features 方法可以抽取出喂入當前數據后吐出來的所有特征圖(第一個卷積和第二個卷積層)
feature_maps = net.retrieve_features(Variable(input_x))
# feature_maps 是有兩個元素的列表,分別表示第一層和第二層卷積的所有特征圖
# 所以 feature_maps[0] 就是第一層卷積的特征圖
plt.figure(figsize = (10, 7))
#有四個特征圖,循環把它們打印出來
for i in range(4):
plt.subplot(1,4,i + 1)
plt.axis('off')
plt.imshow(feature_maps[0][0, i,...].data.numpy())
觀察第二層卷積的卷積核
一共有8個卷積核,因此繪制出的卷積核共有8列。
plt.figure(figsize = (15, 10))
for i in range(4):
for j in range(8):
plt.subplot(4, 8, i * 8 + j + 1)
plt.axis('off')
plt.imshow(net.conv2.weight.data.numpy()[j, i,...])
第二層卷積核都是什么東西?
下面的代碼用於在輸入特定圖像的時候,繪制出第一層卷積核所對應的 4 個特征圖(feature map)的樣子。
plt.figure(figsize = (10, 7))
for i in range(8):
plt.subplot(2,4,i + 1)
plt.axis('off')
plt.imshow(feature_maps[1][0, i,...].data.numpy())
卷積神經網絡的魯棒性試驗
在本小節中將隨機挑選一張測試圖像,把它往左平移 w 個單位,然后:
1.考察分類結果是否變化
2.考察兩層卷積對應的featuremap們有何變化
# 提取中test_dataset中的第idx個批次的第0個圖的第0個通道對應的圖像,定義為a。
a = test_dataset[idx][0][0]
# 平移后的新圖像將放到b中。根據a給b賦值。
b = torch.zeros(a.size()) #全0的28*28的矩陣
w = 3 #平移的長度為3個像素
# 對於b中的任意像素i,j,它等於a中的i,j+w這個位置的像素
for i in range(a.size()[0]):
for j in range(0, a.size()[1] - w):
b[i, j] = a[i, j + w]
# 將b畫出來
muteimg = b.numpy()
plt.axis('off')
plt.imshow(muteimg)
# 把b喂給神經網絡,得到分類結果pred(prediction是預測的每一個類別的概率的對數值),並把結果打印出來
prediction = net(Variable(b.unsqueeze(0).unsqueeze(0)))
pred = torch.max(prediction.data, 1)[1]
print('預測結果:', pred)
#提取b對應的featuremap結果
feature_maps = net.retrieve_features(Variable(b.unsqueeze(0).unsqueeze(0)))
plt.figure(figsize = (10, 7))
for i in range(4):
plt.subplot(1,4,i + 1)
plt.axis('off')
plt.imshow(feature_maps[0][0, i,...].data.numpy())
plt.figure(figsize = (10, 7))
for i in range(8):
plt.subplot(2,4,i + 1)
plt.axis('off')
plt.imshow(feature_maps[1][0, i,...].data.numpy())
五、總結
我們搭建了一個小型的卷積神經網絡來進行手寫體數字的識別。
除了卷積、池化等基本概念以外,我們還學習了一些小的技術,這包括:Dropout技術:防止過擬合的一種方法。
PyTorch中對數據集、數據加載器和采樣器的封裝和使用。