機器學習從入門到放棄:卷積神經網路CNN(二)

Blackbinbin發表於2024-03-10

一、前言

  透過上一篇文章,我們大概瞭解了卷積是什麼,並且分析了為什麼卷積能在影像識別上起到巨大的作用。接下來,廢話不多話,我們自己嘗試動手搭建一個簡易的CNN網路。

二、準備工作

  在開始的時候,我們首先概括一下卷積所需要進行的工作:

  1. 定義一個卷積核:卷積核是一個小的矩陣(例如3x3或5x5),包含一些數字。這個卷積核的作用是在影像中識別特定型別的特徵,例如邊緣、線條等,也可能是難以描述的抽象特徵。
  2. 卷積核滑過影像:卷積操作開始時,卷積核會被放置在影像的左上角。然後,它會按照一定的步長(stride)在影像上滑動,可以是從左到右,也可以是從上到下。步長定義了卷積核每次移動的距離。
  3. 計算點積:在卷積核每個位置,都會計算卷積核和影像對應部分的點積。這就是將卷積核中的每個元素與影像中對應位置的畫素值相乘,然後將所有乘積相加。
  4. 生成新的特徵圖:每次計算的點積結果被用來構建一個新的影像,也稱為特徵圖或卷積圖。
  5. 重複以上過程:通常在一個 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

相關文章