一、前言
透過上一篇文章,我們大概瞭解了卷積是什麼,並且分析了為什麼卷積能在影像識別上起到巨大的作用。接下來,廢話不多話,我們自己嘗試動手搭建一個簡易的CNN網路。
二、準備工作
在開始的時候,我們首先概括一下卷積所需要進行的工作:
- 定義一個卷積核:卷積核是一個小的矩陣(例如3x3或5x5),包含一些數字。這個卷積核的作用是在影像中識別特定型別的特徵,例如邊緣、線條等,也可能是難以描述的抽象特徵。
- 卷積核滑過影像:卷積操作開始時,卷積核會被放置在影像的左上角。然後,它會按照一定的步長(stride)在影像上滑動,可以是從左到右,也可以是從上到下。步長定義了卷積核每次移動的距離。
- 計算點積:在卷積核每個位置,都會計算卷積核和影像對應部分的點積。這就是將卷積核中的每個元素與影像中對應位置的畫素值相乘,然後將所有乘積相加。
- 生成新的特徵圖:每次計算的點積結果被用來構建一個新的影像,也稱為特徵圖或卷積圖。
- 重複以上過程:通常在一個 CNN 中,我們會有多個不同的卷積核同時進行卷積操作。這意味著我們會得到多個特徵圖,每個特徵圖捕捉了原始影像中的不同特徵。
上圖中間的矩陣就是所謂的卷積核,又稱為濾波器。這個濾波器可以幫助我們觀測到一定區域大小的畫素資訊,並且透過卷積計算,變成”低頻“的資訊特徵,比如上面我們提到的一些影像的邊緣,紋理等等。當權重係數(卷積核)的引數改變時,它可以提取的特徵型別也會改變。所以訓練卷積神經網路時,實質上訓練的是卷積核的引數。如下圖所示,是一次卷積計算的過程:
一個核心從影像的左上角開始滑動,將核所覆蓋的畫素值與相應的核值相乘,並對乘積求和。結果被放置在新影像中與核的中心相對應的點處。上面的235其實就是計算所得出的灰度值,當一個核心走完整個圖片之後,得出的結果大概是這樣子:
三、CNN架構
當只有一層CNN結構時,一般都會有如下幾個層級,來幫助我們進行一次卷積,訓練對應的特徵。
- 輸入層(Input Layer):
輸入層負責接收原始資料,例如影像。每個節點對應輸入資料的一個特徵。
- 卷積層(Convolutional Layer):
卷積層是CNN的核心。它透過應用卷積操作來提取影像中的特徵。每個卷積層包含多個卷積核(也稱為濾波器),每個卷積核負責檢測輸入中的不同特徵。卷積操作透過滑動卷積核在輸入上進行計算,並生成特徵圖。
- 啟用函式層(Activation Layer):
在卷積層之後,一般會新增啟用函式,例如ReLU(Rectified Linear Unit),用於引入非線性性。這有助於模型學習更復雜的模式和特徵。
- 池化層(Pooling Layer):
池化層用於減小特徵圖的空間維度,降低計算複雜度,並減少過擬合風險。常見的池化操作包括最大池化和平均池化。
- 全連線層(Fully Connected Layer):
全連線層將前面層的所有節點與當前層的所有節點連線。這一層通常用於整合前面層提取的特徵,並生成最終的輸出。在分類問題中,全連線層通常輸出類別的機率分佈。
- 輸出層(Output Layer):
輸出層給出網路的最終輸出,例如分類的機率分佈。通常使用softmax函式來生成機率分佈。
輸入層就不用解釋了,畢竟在全連線網路中我們已經對它有了一定的瞭解,我們首先看看在卷積層中,具體是怎麼實現的。在我們接下來要做的 MNIST 手寫數字識別的資料集中,我們用其中的圖片來舉例,例如 8 這個圖片:
在卷積層中左邊黑色為原圖,中間為卷積層,右邊為卷積後的輸出。我們可以注意到,當我們是不用的卷積核時,所對應的結果是不同的。
上面第一種卷積核所有元素值相同,所以它可以計算輸入影像在卷積核覆蓋區域內的平均灰度值。這種卷積核可以平滑影像,消除噪聲,但會使影像變得模糊。第二種卷積核可以檢測影像中的邊緣,可以看到輸入的8的邊緣部分顏色更深一些,在更大的圖片中這種邊緣檢測的效果會更明顯。
需要注意的是,雖然上邊說道不同的卷積核有著不同的作用,但是在卷積神經網路中,卷積核並不是手動設計出來的,而是透過資料驅動的方式學習得到的。這就是說,我們並不需要人工設計出特定的卷積核來檢測邊緣、紋理等特定的特徵,而是讓模型自己從訓練資料中學習這些特徵,即模型可以自動從複雜資料中學習到抽象和複雜的特徵,這些特徵可能人工設計難以達到。
在卷積過程中需要注意的兩個引數是:步長和零填充。如果兩者最佳化得當,可以讓CNN的效果更加好。
1. 步長 - Strade
在卷積神經網路(CNN)中,"步長"(stride)是一個重要的概念。步長描述的是在進行卷積操作時,卷積核在輸入資料上移動的距離。在兩維影像中,步長通常是一個二元組,分別代表卷積核在垂直方向(高度)和水平方向(寬度)移動的單元格數。例如,步長為1意味著卷積核在每次移動時,都只移動一個單元格,這就意味著卷積核會遍歷輸入資料的每一個位置;同理,如果步長為2,那麼卷積核每次會移動兩個單元格。如下圖,就是 strade=2 時,卷積後所得的結果:
步長的選擇會影響卷積操作的輸出尺寸。更大的步長會產生更小的輸出尺寸,反之同理。
之所以設定步長,主要考慮以下幾點:
- 降低計算複雜性:當步長大於1時,卷積核在滑動過程中會"跳過"一些位置,這將減少輸出的尺寸並降低後續層的計算負擔。
- 模型的可擴充套件性:增大步長可以有效地降低網路層次的尺寸,使得模型能處理更大尺寸的輸入圖片。
- 控制過擬合:過擬合是指模型過於複雜,以至於開始"記住"訓練資料,而不是"理解"資料中的模式。透過減少模型的複雜性,我們可以降低過擬合的風險。
- 減少儲存需求:更大的步長將產生更小的特徵對映,因此需要更少的儲存空間。
2. 零填充 - Zero Padding
注意上面的圖,我們發現底部輸入圖片的中間十字部分,被輸入了兩次。並且輸入影像與卷積核進行卷積後的結果中損失了部分值,輸入影像的邊緣被“修剪”掉了(邊緣處只檢測了部分畫素點,丟失了圖片邊界處的眾多資訊)。這是因為邊緣上的畫素永遠不會位於卷積核中心,而卷積核也沒法擴充套件到邊緣區域以外。這個結果我們是不能接受的,有時我們還希望輸入和輸出的大小應該保持一致。為解決這個問題,可以在進行卷積操作前,對原矩陣進行邊界填充(Padding),也就是在矩陣的邊界上填充一些值,以增加矩陣的大小,通常都用”空“來進行填充。
如果定義輸入層的邊長是M,卷積核的邊長是K,填充的圈數為P,以及步長的長度為S,我們可以推匯出卷積之後輸出的矩陣大小為:
啟用層就不解釋了,一般來說引入啟用層只是引入非線性,官方指導中也說明,一般使用 Relu 即可。而池化層則其實是為了讓模型具有泛化的能力,其實說白了就是為了避免模型的 overfit,使用池化層讓模型忘記一些之前學過的特徵,這裡的做法其實在後面的很多模型中都能看到。
池化層主要採用最大池化(Max Pooling)、平均池化(Average Pooling)等方式,對特徵圖進行操作。以最常見的最大池化為例,我們選擇一個視窗(比如 2x2)在特徵圖上滑動,每次選取視窗中的最大值作為輸出,如下圖這就是最大池化的工作方式:
大致可以看出,經過池化計算後的影像,基本就是左側特徵圖的“低畫素版”結果。也就是說池化運算能夠保留最強烈的特徵,並大大降低資料體量。
四、手寫數字CNN設計
接下來,我們可以使用上面提到的 CNN 的各個層,給我們自己的手寫數字識別設計網路架構。其實就是讓 卷積層 -> Relu -> 池化層 疊加 N 層,然後最後最後再加入全連線層,進行分類。
下圖就是具體的網路架構圖
用程式碼實現如下:
import torch from torch import nn from torch.nn import functional as F from torch import optim import torchvision from matplotlib import pyplot as plt from utils import plot_curve, plot_image, one_hot, predict_plot_image # step 1 : load dataset batch_size = 512 # https://blog.csdn.net/weixin_44211968/article/details/123739994 # DataLoader 和 dataset 資料集的應用 train_loader = torch.utils.data.DataLoader( torchvision.datasets.MNIST('./data', train=True, download=True, transform=torchvision.transforms.Compose([ torchvision.transforms.ToTensor(), torchvision.transforms.Normalize( (0.1307,), (0.3081,) ) ])), batch_size=batch_size, shuffle=True ) test_loader = torch.utils.data.DataLoader( torchvision.datasets.MNIST('./data', train=True, download=True, transform=torchvision.transforms.Compose([ torchvision.transforms.ToTensor(), torchvision.transforms.Normalize( (0.1307,), (0.3081,) ) ])), batch_size=batch_size, shuffle=False ) # step 2 : CNN網路 class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() # 圖片是灰度圖片,只有一個通道 # 第一層 self.conv1 = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=5, stride=1, padding=2) self.relu1 = nn.ReLU() self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 第二層 self.conv2 = nn.Conv2d(in_channels=16, out_channels=32, kernel_size=5, stride=1, padding=2) self.relu2 = nn.ReLU() self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 全連線層 self.fc1 = nn.Linear(in_features=7 * 7 * 32, out_features=256) self.relufc = nn.ReLU() self.fc2 = nn.Linear(in_features=256, out_features=10) # 定義前向傳播過程的計算函式 def forward(self, x): # 第一層卷積、啟用函式和池化 x = self.conv1(x) x = self.relu1(x) x = self.pool1(x) # 第二層卷積、啟用函式和池化 x = self.conv2(x) x = self.relu2(x) x = self.pool2(x) # 將資料平展成一維 x = x.view(-1, 7 * 7 * 32) # 第一層全連線層 x = self.fc1(x) x = self.relufc(x) # 第二層全連線層 x = self.fc2(x) return x # step3: 定義損失函式和最佳化函式 # 學習率 learning_rate = 0.005 # 定義損失函式,計算模型的輸出與目標標籤之間的交叉熵損失 criterion = nn.CrossEntropyLoss() model = CNN() optimizer = optim.SGD(model.parameters(), lr=learning_rate, momentum=0.9) # step4: 模型訓練 train_loss = [] # 定義迭代次數 device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") # 將神經網路模型 net 移動到指定的裝置上。 model = model.to(device) # 為了和全連線網路的訓練次數一致,我們採用相同的迭代次數 total_step = len(train_loader) num_epochs = 3 for epoch in range(num_epochs): for i, (images, labels) in enumerate(train_loader): images = images.to(device) labels = labels.to(device) optimizer.zero_grad() # 清空上一個batch的梯度資訊 # 將輸入資料 inputs 喂入神經網路模型 net 中進行前向計算,得到模型的輸出結果 outputs。 outputs = model(images) # 使用交叉熵損失函式 loss = criterion(outputs, labels) # 使用反向傳播演算法計算模型引數的梯度資訊 loss.backward() # 更新梯度 optimizer.step() train_loss.append(loss.item()) # 輸出訓練結果 if (i + 1) % 100 == 0: print('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}'.format(epoch + 1, num_epochs, i + 1, total_step, loss.item())) print('Finished Training') # 列印 loss 損失圖 plot_curve(train_loss) # step 5 : 準確度測試 # 測試CNN模型 with torch.no_grad(): # 進行評測的時候網路不更新梯度 correct = 0 total = 0 for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print('Accuracy of the network on the 10000 test images: {} %'.format(100 * correct / total))
損失可以看到經過兩層的卷積層之後,雖然沒有全連線層的Loss那麼低,但是下降的還是非常快的,最後趨於平穩。
經過三個epoch的訓練,結果如下:
Epoch [1/3], Step [100/118], Loss: 0.4683 Epoch [2/3], Step [100/118], Loss: 0.2781 Epoch [3/3], Step [100/118], Loss: 0.1155 Finished Training Accuracy of the network on the 10000 test images: 96.47 %
我們可以驚奇的發現,這裡test資料中預測的準確率竟然高達 96.47%。要知道我們使用好幾層的全連線網路,最後雖然 Loss 值非常低,但是在 test 資料中的準確度都只是徘徊在 90% 左右。而我們在使用相同層數,同等訓練 epoch 的情況下,我們就把識別準確率提高了7%左右,這真是令人興奮的進步!!!
以上就是CNN的全部實踐過程了,這個系列的一小節就到此結束了,隨著學習深入,我發現機器學習中比較能做出成果的是深度學習,所以這幾篇都是關於深度學習的內容。後面會更多的更新機器學習相關知識,我只是知識的搬運工,下期再見~
Reference
[1] https://zhuanlan.zhihu.com/p/635438713
[2] https://mlnotebook.github.io/post/CNN1/
[3] https://poloclub.github.io/cnn-explainer/#article-convolution
[4] https://blog.csdn.net/weixin_41258131/article/details/133013757
[5] https://alexlenail.me/NN-SVG/LeNet.html