【Python】TensorFlow學習筆記(六):卷積的那些小事

就算不懂卷積運算,在大多數的時刻都不會妨礙我們使用它的心情。
但,若能「更深入地」了解卷積背後的運作原理,心情肯定會好得無以復加吧!

卷積是什麼?

關於卷積,英文為 Convolution。

首先我們要知道的是,它有很多同義詞。
例如:摺積、疊積、迴旋積。

撇開前面那些讓初學者感到恐懼的形容詞來說,「積」的意義還是很直觀的 —
就是「向量內積」的意思,內積這個動作已經包含了「點對點」的相乘與加總。

欸?等等!
我好像聽到相乘與相加,對嗎?

這不就是代數中的「多項式乘法」?
或是影像處理中的「空間濾波」?
又還有股票市場的「移動均線」?

對,都對!卷積無所不在,所以我們更該要懂它,不是嗎?

在一開始,夏恩先用比較簡單的方式來說明「卷積」這個行為。

想像有一隻野生的夏恩站在湖邊,周圍有悅耳的蟲鳴鳥叫,
湖面映著暖陽,波光粼粼。只見四周空無一人,空氣中瀰漫著大自然的芬芳。

還有就是夏恩感到很無聊。
於是伸手拿了一顆一公斤的石頭扔到湖裡。

噗通一聲!只見湖中盪起片片水花。

好的~謝謝夏導。
情境到此為止!

言歸正傳,根據上述情境,我們接著來看:

這時候,我們會說「丟石頭」這個動作叫做:
脈衝函數 ( Impulse )

另外,湖泊盪起片片水花,叫做:
響應函數 ( response )

我們假定整個脈衝響應系統是一個線性時不變系統
也就是說,夏恩丟一公斤的石頭,若會產生一公尺的浪花的話 ,
丟三公斤的石頭,就會產生三公尺的浪花,這就是線性時不變系統。

最後,還請各位看倌不要質疑情境的合理性,牛頓今天不在家。

接著,我們令脈衝函數 ( 丟石頭 ) 為 g(t):

在 t=0 時刻,丟一公斤的石頭。
在 t=1 時刻,丟兩公斤的石頭。
在 t=2 時刻,丟三公斤的石頭。

另外,響應函數 ( 湖泊 ) 為 f(τ)。( τ 的念法是 tau )

若丟下一公斤的石頭,或稱「一單位的脈衝」,會產生四個時刻的波紋。
請注意到,丟石頭是 t 的函數,表示夏恩在 t 時刻「主動」丟出不同重量的石頭。
而湖泊是 τ (tau) 的函數,不隨時間 t 而改變,而是「被動」承接脈衝,所產生的固定反應。

也就是說,若夏恩都不丟石頭,就算 t 時刻從 0 增加到 100,湖面本身也不會有任何變化。

那麼,回到問題本身。
若依照夏恩的脈衝函數,按時把石頭丟到湖裡,會得到什麼結果呢?

在 t=0 時刻,丟一公斤的石頭,得到 [1 0 1 0] 的響應。
在 t=1 時刻,丟兩公斤的石頭,得到 [2 0 2 0] 的響應。
在 t=2 時刻,丟三公斤的石頭,得到 [3 0 3 0] 的響應。

然後,把 t = 0 ~ 2 所產生的響應疊加,我們就可以觀察到整個系統的狀態。

其中可以看到上圖中, h(t) 代表整個系統疊加態,
另外,(t-τ) 放在響應或是脈衝函數上結果一樣,讀者可以自行動手驗證一下。

看到這邊,我們就能下一個結論:
所謂的「卷積」,也就是做一個「滑動、相乘再相加」的動作。

那麼卷積的「卷」是在卷什麼?
其實「卷」和其他「疊」、「摺」之類的都只是形容詞而已。

當然,硬要解釋的話也不是不行:

若我們令 τ 為 X;令 t-τ 為Y。
則,依照卷積定義:就是 sum( g(X)*f(Y) ),且 X + Y = t。

先令 t = 0 ,這樣比較好理解。

也就是說,可以令 g 為 X 的函數,以 X 軸表示之;
同理,f 為 Y 的函數,以 Y 軸表示之。

g 和 f 可以就兩個一維座標軸,組成一個 XY 平面。
卷積就像是一張由 g 和 f 組成的紙,沿著 Y = -X  這條直線卷起來!

好的,解釋完畢。

另一方面,卷積又稱作迴旋積,又怎麼說?

若想了解迴旋的由來,夏恩建議從數學式來看:
τ 和 (t-τ) 就是翻轉180度的意思,畫成圖會更容易理解。

當 f * g 的時候,f 和 g 函數都是「從零開始」進行向量內積。
也就是說,必有其中一方得先翻轉180度之後,再開始相乘相加的動作。

下圖是用一維向量來描述,在高維向量中也是相同的概念。

卷積怎麼用?

說了這麼多,總該要了解一下這傢伙該怎麼用才對。

首先,可以用在多項式相乘的問題上,
例如計算:(3X^3+5X^2+X+2) * (5X+1),就可以用卷積來算:

假設參與卷積的向量為 U 和 V,那麼運算後的向量長度為:

length(U) + length(V) - 1

若推廣到二維陣列,那麼 row 和 column 分開計算。
例如:4*2的陣列和5*5的陣列進行卷積運算,結果為 (4+5-1)*(2+5-1) = 8*6 的陣列。

在 MATLAB 中,可以指定 'same' 作為輸入參數,這樣能使輸出入的大小一致。
既然都提到 MATLAB,那就再來一個範例吧:

利用卷積運算,對影像做水平微分。
首先載入一張勝利的 V ,接著下指令:

Img = imread( 'test.jpg' );
Img1 = conv2( Img, [1, -1], 'same' );
Img1 = abs(Img1);
imshow(Img1)

就可以得到下圖:

卷積的用途實在太廣泛了,例子多到不勝枚舉,更多例子就請讀者自行查找吧!

Tensorflow 的卷積函數

咱們的話題終於又回到 Tensorflow 了。

身為影像工程師的我們,最常用的還是二維卷積運算。
所以我們針對 TF 所提供的卷積方法做進一步的探討。

再複習一次,卷積就是「滑動、相乘再相加」。
一維卷積運算是如此,二維卷積也是一樣。

以下,夏恩引用一張到處都看得到示意圖來說明:


原始圖片來源:http://cs231n.github.io/convolutional-networks/  (感謝網友李東東熱心提供)

有關上圖的基本設定如下:

Input image size = [N_batch, 5, 5, 3]
filter size = [3, 3, 3, 2]
padding = 'SAME'
strides = [1, 2, 2, 1]

對於卷積函數的參數很陌生嗎?
沒關係,在這邊先有印象就好,接著夏恩會仔細說明各參數的意義。

卷積函數是建置神經網路的重要支架,
在 Tensorflow 中至少有五個以上的卷積函數可供使用。

例如:
tf.nn.convolution
tf.nn.conv1d
tf.nn.conv2d
tf.nn.conv3d ...

在這兒就以較常用的 tf.nn.conv2d 來說明:

tf.nn.conv2d(input, filter, strides, padding, 
             use_cudnn_on_gpu=True, 
             data_format='NHWC', 
             dilations=[1, 1, 1, 1], 
             name=None )

1. input & data_format:

張量,資料類型為 float32 或 float64,預設資料格式由 data_format 參數設定。
預設為 data_format = 'NHWC',即輸入格式為 [ batch, height, width, channels ];
若改為 data_format = 'NCHW',即輸入格式為 [ batch, channels, height, width ];

batch:  圖片數量
height: 圖片高度
width:   圖片寬度
channels:圖片通道數,例如 RGB 影像 channels = 3;灰階影像 channels = 1。

2. filter:

卷積核,資料類型為須與 input 相同,
其輸入格式為:[ filter_height, filter_width, in_channels,  out_channels]。

filter_height:   卷積核的長度
filter_width:    卷積核的寬度
in_channels:  輸入圖片的通道數
out_channels:輸出圖片的通道數

在架構卷積網路的時候,最常出錯的地方就是(輸入/輸出)圖片的通道數。
不過不用擔心,若有設定錯誤的情況,系統會明白地指出來,不會有找不到問題的情況發生。

3. strides:

移動步數,資料格式為 List int ,長度為 4 的一維張量,每一個值與 input 各維度對應,
特別注意的是,strides 的第一個和第四個值必定為 1 ,即 strides[0] = strides[3] = 1。
基本格式可視為: strides = [1, stride, stride, 1],如上圖,strides = [1, 2, 2, 1],
表示卷積核在 height, width 的維度上,一次移動 2 個像素。

4. padding:

直接舉例說明,假設 input width = 13;filter width = 6;stride = 5。

" VALID " = 卷積時,不做任何填充,最右邊 column 與最下面 row 會被丟棄。對於 " VALID " 輸出的形狀是 ceil[ (I+F-1)/S ],也就是 input 的長度 + filter 的長度 - 1 再除以 Stride 值,最後無條件進位。

inputs:         1  2  3  4  5  6  7  8  9  10 11 (12 13)
               |________________|                dropped
                              |_________________|

" SAME " = 嘗試向左和向右均勻填充,但如果要添加的列數是奇數,它將向右添加額外的列,如本例中的情況。對於 "SAME " 輸出的形狀是 ceil[  I / S ],也就是 input 的長度除以 Stride 值,最後無條件進位。

            pad|                                      |pad
inputs:      0 |1  2  3  4  5  6  7  8  9  10 11 12 13|0  0
            |________________|
                           |_________________|
                                          |________________|

5. use_cudnn_on_gpu:

布林值,是否使用 GPU 訓練。

6. dilations:

卷積核擴張參數,資料格式為 List int ,長度為 4 的一維張量,每一個值與 input 各維度對應,
其格式如同 strides,即第一個和第四個值必定為 1 ,即 dilations[0] = dilations[3] = 1。

該參數會影響到卷積核的大小,預設為 dilations = [1, 1, 1, 1] ,什麼事都照舊。
若 dilations = [1, k, k, 1],則卷積核中的每個元素都會有一個 k-1 大小的空格。

很抽象嗎?沒關係,夏恩可以舉例:若原本的卷積核為 3x3 的一矩陣,如下:

filter = [ 1, 1, 1,
           1, 1, 1,
           1, 1, 1 ]

當我們把 dilations 改為 [1, 2, 2, 1],此時 filter 的形狀如下:

filter = [ 1, 0, 1, 0, 1,
           0, 0, 0, 0, 0,
           1, 0, 1, 0, 1,
           0, 0, 0, 0, 0,
           1, 0, 1, 0, 1, ]

使用 dilations 的目的是為了增加卷積核的感受野,降低資料損失。
若沒有特別想針對這一塊進行優化的話,建議使用預設值就好。

7. name

這個參數其實可以不用管它,其功能就是幫這個卷積運算子取個名字而已。

到這邊,參數介紹完畢,最後夏恩用一個簡單的小程式,來演示卷積函數的運作方式,
相關的程式放在最後面的附錄,請自行參閱。

小結

在很久以前,夏恩就很想介紹卷積這件事。
但是有很多地方其實夏恩自己也沒有弄得很明白,
許多地方是處於會寫、會用、卻不太清楚細節的狀態 ——

反正專案有趕出來交差就好!
誰管你卷積為什麼要轉180度?

這當然是不好的行為,你知道我知道,大家都知道。

可是真正花時間去弄懂其中的細節是非常需要勇氣的,
畢竟投資一定有風險,閱讀「數學理論」前請詳閱公開......

咳咳!...恩。

總之,夏恩這次幸運地投資有得到回報~
為了表達自己的喜悅,特別留下本文作為紀念。

下一篇將是本系列文的最後一章。
想知道該如何搭建卷積網路的話,請繼續看:
【Python】TensorFlow學習筆記(七):卷積深深深幾許

參考文獻

1. 維基百科:卷積
2. 維基百科:線性系統
3. 如何通俗易懂地解釋卷積
4. MathWorks:conv
5. TensorFlow中CNN的两种padding方式"SAME"和"VALID"
6. What is the difference between 'SAME' and 'VALID' padding in tf.nn.max_pool of tensorflow?
7. 【Tensorflow】tf.nn.atrous_conv2d如何實現空洞卷積?

附錄

# -*- coding: utf-8 -*-
"""
程式簡介:卷積函數測試

2018.12.26 Shayne
"""

import cv2
import numpy as np
import tensorflow as tf
import matplotlib.pyplot as plt
# 建立卷積核:高通濾波器
temp = np.array([ [-1, -1, -1],
                  [-1,  8, -1],
                  [-1, -1, -1] ], dtype='float32')

# 轉成 tf.nn.conv2d 所須格式
kernal = tf.reshape(tf.Variable(temp), [3, 3, 1, 1])
print(kernal) 
>>> Tensor("Reshape_1:0", shape=(3, 3, 1, 1), dtype=float32)
# 載入灰階影像,並二值化
I1 = cv2.imread('test1.jpg', 0)
_, I2 = cv2.threshold(I1, 0, 255, cv2.THRESH_OTSU)

# 轉 uint8 格式成 float32
I2 = I2.astype('float32')

# 轉換成卷積輸入模式
x_img = tf.reshape(I2, [-1, I2.shape[0], I2.shape[1], 1])

plt.imshow(I2, cmap='gray')
plt.show()
# 卷積運算子
y_conv = tf.nn.conv2d(x_img, kernal, strides=[1, 1, 1, 1], padding='SAME')

# 啟動計算圖
with tf.Session() as sess:
    
    # 變數需要初始化
    sess.run( tf.global_variables_initializer() )
    
    # 執行運算子
    result = sess.run(y_conv)
    
    # 展示結果
    I3 = tf.reshape(result, [I2.shape[0], I2.shape[1]])
    I3 = I3.eval()
    
    plt.imshow(I3, cmap='gray')
    plt.show()