在上一篇博客CNN核心概念理解中,我們以LeNet為例介紹了CNN的重要概念。在這篇博客中,我們將利用著名深度學習框架PyTorch實現LeNet5,並且利用它實現手寫體字母的識別。訓練數據采用經典的MNIST數據集。本文主要分為兩個部分,一是如何使用PyTorch實現LeNet模型,二是實現數據准備、定義網絡、定義損失函數、訓練、測試等完整流程。
一、LeNet模型定義
LeNet是識別手寫字母的經典網絡,雖然年代久遠,但從學習的角度仍不失為一個優秀的范例。要實現這個網絡,首先來看看這個網絡的結構:

這是一個簡單的前向傳播的網絡,它接受32x32圖片作為輸入,經過卷積、池化和全連接層的計算,最終給出輸出結果。實現的過程並不復雜:
1 from torch import nn 2 from torch.nn import functional as F 3 4 class LeNet(nn.Module): 5 def __init__(self): 6 super(LeNet, self).__init__() 7 # 1 input image channel, 6 output channels, 5x5 square convolution 8 self.conv1 = nn.Conv2d(1, 6, 5, padding=2) 9 self.conv2 = nn.Conv2d(6, 16, 5) 10 # an affine operation: y = Wx + b 11 self.fc1 = nn.Linear(16 * 5 * 5, 120) 12 self.fc2 = nn.Linear(120, 84) 13 self.fc3 = nn.Linear(84, 10) 14 15 16 def forward(self, x): 17 x = F.relu(self.conv1(x)) 18 # Max pooling over a (2, 2) window 19 x = F.max_pool2d(x, 2) 20 x = F.relu(self.conv2(x)) 21 x = F.max_pool2d(x, 2) 22 23 x = x.view(x.size(0), -1) 24 x = F.relu(self.fc1(x)) 25 x = F.relu(self.fc2(x)) 26 x = self.fc3(x) 27 return x
我們繼承了nn.Module模塊,在__init__中完成了卷積層和全連接層的初始化。值得注意的是由於池化層沒有參數,因此並沒有一起初始化。初始化參數包括輸入個數、輸出個數,卷積層的參數還有卷積核大小。除此之外在第一個卷積層C1中還定義了padding,這是因為數據集中圖片是28x28的,padding=2表明輸入的時候在圖片四周各填充2個像素的空白,將輸入變成了32x32。
在forward中我們實現了前向傳播。這里我們根據定義對輸入依次進行卷積、激活、池化等操作,最后返回計算結果。在全連接層之前,有一個對數據的展開操作,我們使用Tensor的view函數實現,這個函數可以將Tensor轉變成任意合法的形狀。我們只定義了forward函數,而沒有定義backword函數,這是因為PyTorch的自動微分功能自動幫我們完成了反向傳播的定義。
LeNet模型這樣就定義完成了。但是需要注意的是,這個網絡和最初LeCun論文中的實現略有不同:
- 原始論文中C3與S2並不是全連接而是部分連接,這樣能減少部分計算量。而現代CNN模型中,比如AlexNet,ResNet等,都采取全連接的方式了。我們的實現在這里做了一些簡化。
- 原文中使用雙曲正切作為激活函數,而我們使用了收斂速度更快的ReLu函數。
- 按照原文描述,網絡最后一層為高斯連接層。而我們為了簡單起見還是用了全連接層。
LeNet其實是一個比較“古老”的模型了,我們不必追求完美的復現,理解其中關鍵的概念即可。
二、准備數據
為PyTorch准備數據非常方便。對於一些經典數據集,PyTorch已經將它們封裝好了,我們可以直接拿來用。當然MNIST數據集也在此列,但是我們仍然定義了自己的數據集,因為這種方法可以處理更通用的情況。為了定義自己的數據集,首先要繼承torch.utils.data.database類,然后實現至少__getitem__和__len__兩個方法。
1 import gzip, struct 2 import numpy as np 3 import torch.utils.data as data 4 5 class MnistDataset(data.Dataset): 6 def __init__(self, path, train=True): 7 self.path = path 8 if train: 9 X, y = self._read('train-images-idx3-ubyte.gz', 10 'train-labels-idx1-ubyte.gz') 11 else: 12 X, y = self._read('t10k-images-idx3-ubyte.gz', 13 't10k-labels-idx1-ubyte.gz') 14 15 self.images = torch.from_numpy(X.reshape(-1, 1, 28, 28)).float() 16 self.labels = torch.from_numpy(y.astype(int)) 17 18 def __getitem__(self, index): 19 return self.images[index], self.labels[index] 20 21 def __len__(self): 22 return len(self.images) 23 24 def _read(self, image, label): 25 with gzip.open(self.path + image, 'rb') as fimg: 26 magic, num, rows, cols = struct.unpack(">IIII", fimg.read(16)) 27 X = np.frombuffer(fimg.read(), dtype=np.uint8).reshape(-1, rows, cols) 28 with gzip.open(self.path + label) as flbl: 29 magic, num = struct.unpack(">II", flbl.read(8)) 30 y = np.frombuffer(flbl.read(), dtype=np.int8) 31 return X, y
由於官網上提供的MNIST數據集是gzip壓縮格式,因此我們在讀取的時候首先要解壓,然后轉成numpy形式,最后轉成Tensor保存起來。之后在__getitem__中返回相應的數據和類別就可以了,__len__函數直接返回數據集的大小。由於MNIST數據集有訓練和測試兩部分,因此需要分類處理。
三、使用數據訓練網絡
我們首先用DataLoader類加載數據集,DataLoader負責將數據轉化成適當的形式放入模型訓練。使用DataLoader可以方便地控制微批次大小、線程數等參數。
1 train_dataset = MnistDataset('./data/') 2 train_loader = data.DataLoader(train_dataset, shuffle=True, batch_size=256, 3 num_workers=4)
這時候可以測試數據有沒有成功加載進來,如圖所示。

下一步定義評價函數和優化器,這一步很重要,但不是本文重點。直接給出代碼:
1 criterion = nn.CrossEntropyLoss(reduction='sum') 2 optimizer = optim.Adam(net.parameters(), lr=1e-3, betas=(0.9, 0.99))
最后的給出訓練過程的簡化版。這個兩層循環就是實際的訓練過程,外層循環控制遍歷數據集的次數,內層循環控制每一次參數更新。
1 for epoch in range(5): 2 for (inputs, label) in train_loader: 3 # zero the parameter gradients 4 optimizer.zero_grad() 5 # forward + backward + optimize 6 output = net(inputs) 7 loss = criterion(output, label) 8 loss.backward() 9 optimizer.step()
三、模型評估
模型經過訓練之后,將測試集輸入放入模型,將輸出和標簽比對可以計算出模型的准確率等信息,進而對模型不斷優化。此外如果想要了解模型到底學到了什么東西,還可以將中間層結果輸出。如圖所示:

這部分代碼沒有給出,完整代碼可以到Github頁面查看。
