CNN簡介

August 27, 2024

Convolutional neural network (CNN)是類神經網絡架構的一類, 相較於完全連接的類神經網絡(如MLP),CNN網絡在全連接的神經層中加入二種新的神經層:Pooling layerConvolutional layer

視域,特徵地圖及Convolution層

Convolutional layer 指的是類神經層之間的節點連接是部份連結,此概念是模仿生物的視神經皮質,如V1, V2層的發現,此外,此類神經層的數值運算是透過一種稱為Convolution的演算方式而達成的。這兩點異於全連結的類神經網絡,具體地說,在MLP中,每一個類神經層的演算是將輸入向量與權重向量進行內積 (dot product),但是Convolutional layer是進行Convolution的運算。

Convolution是一項線性代數中常用的矩陣代數的演算方法,以下我們將分節說明。

輸入層的節點雖然仍用來表徵外界的圖片,例如每一個特徵像素對應一個輸入節點,Convolutional layer的每一個神經節點接收的前一層訊息侷限於某一個範圍的節點,此點類似於視神經皮質的視域(Receptive field)機制。

視域 指的是視網膜上的每一個神經細胞僅感知到落於外界空間中,某一個小範圍的光線刺激,此點和MLP架構,每一個神經節點可以接收到前一神經層所有節點的訊息相異。Convolutional layer採用視域的概念,在Convolutional layer中每一個神經節點僅接收到前一神經層的某一小塊的感知區域的神經元,但不與其他同在前一神經層的節點有任何連接。簡言之,Convolutional layer上的每一個神經節點和前一層並非是節點對節點的完全連接,而是節點對視域的連接。

特徵地圖(Feature maps) 是認知科學中視覺搜尋的理論之一,特徵地圖假設,外在影像的初期知覺表徵,會被細分為多個不同的特徵地圖,例如一張彩色影像,在經過視網膜處理後,會被視神經、V1、V2及更高層的視皮質分析為多種不同的特徵地圖,比方說在一張 28 x 28 的影像檔,特徵地圖可以被想像是每個像素點上:

  1. 組成各物體的線條的方向,
  2. 物體受反射的光線的對比強度,
  3. 紅色色調的分布,
  4. 顏色的飽和度分布,
  5. 其他可能的基礎視知覺。

CNN架構模仿此視覺搜尋的概念,使用同樣的術語「特徵地圖」,但其實現方式略有不同。比方說,當一個28 x 28的灰階影像輸入一層Convoluional layer後,它可能會被分為32個不同的 特徵地圖。異於認知科學,在類神經網絡中,這32個特徵地圖是表徵那些基礎的視知覺訊息並非重點所在,類神經網絡的重點在於,在模型最佳化的過程中如何找到最適當的權重參數,最適當的特徵地圖數量,使模型預測率儘可能愈高。

Convolution

Convolution 可被視為加、減、乘、除外的另一種數值運算方法,和傳統的數值運算不同的是,Convolution是使用在向量或是二維以上的矩陣的數值運算方法,基本上,Convolution運算有兩主要步驟:

  1. 於運算時,規律地在輸入向量做移動,之後據此取出輸入向量中某一段數值;
  2. 將此部分輸入向量和 Kernel向量的反轉進行向量內積的運算。

以下我們用一個簡單的例子來具體地說明此概念。比方說,我們決定移動距離是二個數值 (shift = 2),輸入向量是,x = [3, 2, 1, 7, 1, 2, 5, 4],kernel向量是 w = [1/2, 3/4, 1, 1/4], 若進行Convolution的運算,輸出向量會是?

x = [3, 2, 1, 7, 1, 2, 5, 4]
w = [1/2, 3/4, 1, 1/4]
wr = w[::-1]  # Python將一維向量反轉的語法

print(w, wr)
# ([0.5, 0.75, 1, 0.25], [0.25, 1, 0.75, 0.5])

p = 0
zero_pad = np.zeros(shape=p)
x_p = np.concatenate([zero_pad, x_p, zero_pad])


n = len(x)
n_p = len(x_p)
m = len(w_r)

在上一段程式碼中,我們使用了「零填充」這個技巧。簡單來說,就是將輸入資料的邊緣補上0。在影像處理中,零填充可以讓卷積運算更完整地提取邊緣特徵,而且不會影響影像的內容。

以下、我們使用雙層的for loop顯示計算的過程:

import numpy as np

y = np.zeros(3)
s = 2
irange = range(0, 5, s)


for i, ielement in enumerate(irange):
    idx = range(ielement, ielement + m)
    print(i, idx)
    for j, jelement in enumerate(idx):
        print(j, x[jelement])
        y[i] += x[jelement] * wr[j]
    print('-----------------------------')
    print(y[i])
    print('-----------------------------')

# 0 range(0, 4)
# 0 3
# 1 2
# 2 1
# 3 7
# -----------------------------
# 7.0
# -----------------------------
# 1 range(2, 6)
# 0 1
# 1 7
# 2 1
# 3 2
# -----------------------------
# 9.0
# -----------------------------
# 2 range(4, 8)
# 0 1
# 1 2
# 2 5
# 3 4
# -----------------------------
# 8.0
# -----------------------------

在上面的例子中,我們可以看到三次遞迴的過程。每次遞迴,都會從輸入向量中取出一個長度為4的片段。由於設置了 stride 為2,所以每次取完片段後,會向右移動2個單位,再取下一個片段。之所以片段長度為4,是因為 kernel 向量也有4個元素。

為了確保每次都能取到長度一致的片段,並且能夠進行向量內積運算,我們通常會使用 zero padding 的方式。當輸入向量的長度不是 kernel 向量長度的倍數時,可以在輸入向量的兩端補零,使其長度變成 kernel 向量長度的倍數。這樣一來,就能保證每次取出的片段長度都與 kernel 向量長度相同,方便後續的計算。

輸出向量的長度

前例中,我們尚未提到的輸出向量的長度是如何決定的,輸出向量長度和zero padding的長度息息相關。在CNN的應用中,較為常見的應用是儘可能維持輸出向量和輸入向量的長度一致,或是變短,此策略稱為same padding。在此情況下,輸出向量長度可以用如下程式碼來計算它。

# // is a floor division operator
output_length = (n + 2*p - m)//s + 1 
y = np.zeros(output_length)

irange = range(0, n_p - m + 1, s)
idx_list = [list(range(ielement, ielement + m)) for ielement in irange]
idx_list

for i in range(output_length):
    y[i] = np.sum(x_p[idx_list[i]] * w_r)

將上面的程序,彙集為一個函式,我們可以得到一維的convolution運算元:

def my_conv1d(x, w, p=0, s=1):
    w_r = np.array(w[::-1])
    x_p = np.array(x)
        
    if p > 0:
        zero_pad = np.zeros(shape=p)
        x_p = np.concatenate([zero_pad, x_p, zero_pad])

    n, n_p, m = len(x), len(x_p), len(w_r)
    output_length = (n + 2*p - m)//s + 1 
    y = np.zeros(output_length)
    irange = range(0, n_p - m + 1, s)

    idx_list = [list(range(ielement, ielement + m)) for ielement in irange]
    for i in range(output_length):
        y[i] = np.sum(x_p[idx_list[i]] * w_r)

    return y    

二維的Convolution

二維的Convolution運算元雖然仍基於相同的原則,其計算及結構整理過程變得相當繁複,因此當資料量大,計算速度事關重要時,多數時候人們是採用廣用的現成程序,如下scipy.signal套件中的程序,來進行Convolution運算,以下展示兩種方法。

import scipy.signal
import numpy as np


def conv2d(X, W, p=(0, 0), s=(1, 1)):
    W_rot = np.array(W)[::-1, ::-1]
    X_orig = np.array(X)

    n1 = X_orig.shape[0]
    n2 = X_orig.shape[1]
    m1 = W_rot.shape[0]
    m2 = W_rot.shape[1]
    n_p1 = n1 + 2*p[0]
    n_p2 = n2 + 2*p[1]

    X_padded = np.zeros(shape=(n_p1, n_p2))
    X_padded[p[0]:p[0] + n1, 
             p[1]:p[1] + n2] = X_orig

    res = []
    for i in range(0, int((n_p1 - m1)/s[0]) + 1, s[0]):
        res.append([])

        for j in range(0, int((n_p2 - m2)/s[1]) + 1, s[1]):
            x_sub = x_padded[i:i+m1, j:j+m2]
            res[-1].append(np.sum(x_sub * W_rot))

    return (np.array(res))


W = np.array([[0.5, 0.7, 0.4], [0.3, 0.4, 0.1], [0.5, 1, 0.5]])
X = np.array([[2, 1, 2], [5, 0, 1], [1, 7, 3]], float)

res = conv2d(X, W, p = (1, 1))
# res = scipy.signal.convolve2d(X, W, mode='same')

# array([[ 4.6,  3.7,  1.6],
#        [ 8.7, 10.6,  7.8],
#        [ 7.5,  6.8,  2.9]])

Pooling layer

CNN的網絡架構除了引進了Convolutional layer外,還添加了另一項新的神經層,稱為Pooling layer。Pooling神經層不做任何數值轉換,僅做數值彙集。它有兩種常用的數值彙集方式:Max pooling及Mean pooling。

Max pooling的彙集方式是從某一區域的數值中,找出其中的最大值,而此值則為此區域節點的輸出到下一神經層的數值。

Mean pooling的彙集方式是從某一區域的數值中,找出其中的平均值,而此值則為此區域節點的輸出到下一神經層的數值。

x = np.array([[2, 1, 7, 1, 2, 5], [5, 0, 3, 4, 1, 2], [1, 7, 8, 3, 3, 0],
              [0, 3, 2, 0, 1, 1], [6, 2, 5, 3, 0, 3], [3, 6, 0, 2, 1, 0]], float)

# array([[2., 1., 7., 1., 2., 5.],
#        [5., 0., 3., 4., 1., 2.],
#        [1., 7., 8., 3., 3., 0.],
#        [0., 3., 2., 0., 1., 1.],
#        [6., 2., 5., 3., 0., 3.],
#        [3., 6., 0., 2., 1., 0.]])

from skimage.measure import block_reduce

max_pooling = block_reduce(x, (3, 3), np.max)
mean_pooling = block_reduce(x, (3, 3), np.mean)
max_pooling, mean_pooling

# (array([[8., 5.],
#         [6., 3.]]),
# array([[3.77777778, 2.33333333],
#        [3.        , 1.22222222]]))

影像頻道

一張尋常的影像常包含了多個不同的頻道,例如彩色圖片經常被分為紅藍綠(RGB)三色頻道,如下圖所示。

channels

因此通常在進行影像辨識時,輸入層還會有頻道數的維度,且每個頻道各自有其權重矩陣(kernel),例如上圖的影像檔的像素值包含了三個600 x 800的矩陣,代表著,長 x 寬 x 影像頻道數 (n1 x n2 x n_channel)。相對應地,權重矩陣也須設置為三維,例如 m1 x m2 x n_channel。除此之外,若我們希望將Convolutional layer輸出多個特徵地圖時, kernel須再增設一個維度,成為四維的tensor,如 m1 x m2 x n_channel x n_feature_map。

import matplotlib.pyplot as plt
import matplotlib.image as mpimg


rfood = mpimg.imread('images/cnn/food_red.png')
gfood = mpimg.imread('images/cnn/food_green.png')
bfood = mpimg.imread('images/cnn/food_blue.png')

food = mpimg.imread('images/cnn/resized_food.jpg')/255
food.shape, battery.shape
# ((600, 800, 3), (600, 800, 3))

fig = plt.figure(figsize=(12, 6))

ax = fig.add_subplot(2, 2, 1)
plt.imshow(food)
plt.axis("off") 

plt.tight_layout()

ax = fig.add_subplot(2, 2, 2)
plt.imshow(rfood)
plt.axis("off") 

plt.tight_layout()

ax = fig.add_subplot(2, 2, 3)
plt.imshow(gfood)
plt.axis("off") 

plt.tight_layout()

ax = fig.add_subplot(2, 2, 4)
plt.axis("off") 
plt.imshow(bfood)

plt.tight_layout()


filename = 'image_channels'
save_fig(filename, tight_layout=False)
plt.show()

CNN演算方法

Convolutional layer 的計算方式和MLP類似,之前我們介紹MLP時用如下公式:

  • y = A(x × wt + b)

我們僅須將向量符號換為矩陣符號,並將相乘的運算,換為Convolution的運算,便可得到Convolutional layer的計算方式:

  • Y = A (X * W + b)

同樣地,此計算結果最後也會經過一轉換方程式(Activation function, A),才會得到最終數值,在Convolutional layer中權重矩陣是即是Convolution運算時的kernel;每一個輸出的特徵地圖則會對應一個偏差值。

如何計算參數數量?

我們可以用下面一簡單的CNN模型為例來了解參數數量計算方法。

model = keras.models.Sequential([
    keras.layers.Conv2D(filters=5, kernel_size=7, activation='relu', 
                        padding="SAME", input_shape=[28, 28, 3]),
    keras.layers.MaxPooling2D(2),
])

model.summary()

上面的Keras的語法指的是:

  1. 設置一層Convolutional layer,此類神經層將會接收的輸入影像是28 x 28的矩陣,而每個影像有三個頻道;此類神經層會使用5個kernel,每個kernel是 7 x 7的矩陣,在Convolution的運算時使用 same zero padding的方法,使用ReLu的Activation function。
  2. 設置一層Pooling layer,此類神經層,使用Max pooling的方法彙集前一層的訊息,並縮減節點數一半。

參數總量的計算式:7 x 7 x 3 x 5個權重參數,加上 5個偏差參數,最後得到740個參數。

┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
 Layer           Output Shape                  Param # 
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
 Conv2D          (None, 28, 28, 5)                 740 
├────────────────┼────────────────────────┼───────────────┤
 MaxPooling2D    (None, 14, 14, 5)                   0 
└───────────── ──┴────────────────────────┴───────────────┘

Dropout

當我們訓練一個模型時,我們希望它能夠學習到資料中的規律,以便對新的資料做出準確的預測。然而,如果模型過於專注於記憶訓練資料中的細節,反而會忽略了更一般的規律。這種現象稱為過度擬合(Overfitting)。Overfitting的模型就像是一個死記硬背的學生,雖然能在考試中取得高分,但卻無法應用所學知識解決新的問題。訓練組的資料便如同學生死記硬背的考試試題,而驗證組及測試組資料便如新的問題。

Dropout是用來處理Overfitting問題的一種方法,它指的是在訓練的過程中將某些內部層的節點暫時移除,例如在下例中,第三神經層至第四神經層四之間,層三有會隨機地選出一半節點,暫時無法傳送訊息到下一層。

model = keras.models.Sequential([
    keras.layers.Conv2D(filters=5, kernel_size=7, activation='relu', 
                        padding="SAME", input_shape=[28, 28, 3]), # Layer 1
    keras.layers.MaxPooling2D(2),  # Layer 2
    keras.layers.Flatten(),
    keras.layers.Dense(units=128, activation='relu'), # Layer 3
    keras.layers.Dropout(0.5),
    keras.layers.Dense(units=64, activation='relu'), # Layer 4
])

小結

CNN借由模仿生物的視神經機制介紹了許多新的概念,這些概念實現於數值演算中成為CNN的基石,其中包含了Convolutional layer、Pooling layer、及Convolution的演算法。本文所介紹的CNN類神經機制在Keras及PyTorch套件中都有不同的實作函數可使用,在下一篇文章中,我們將以實際的例子來討論如何使用CNN的進行影像分類。