【深度學習系列】卷積神經網路CNN原理詳解(一)——基本原理

精彩回顧:

【深度學習系列】PaddlePaddle之手寫數字識別

上篇文章我們給出了用paddlepaddle來做手寫數字識別的示例,並對網路結構進行到了調整,提高了識別的精度。有的同學表示不是很理解原理,為什麼傳統的機器學習演算法,簡單的神經網路(如多層感知機)都可以識別手寫數字,我們要採用卷積神經網路CNN來進行別呢?CNN到底是怎麼識別的?用CNN有哪些優勢呢?我們下面就來簡單分析一下。在講CNN之前,為避免完全零基礎的人看不懂後面的講解,我們先簡單回顧一下傳統的神經網路的基本知識。


神經網路的預備知識

為什麼要用神經網路?

  • 特徵提取的高效性。

  大家可能會疑惑,對於同一個分類任務,我們可以用機器學習的演算法來做,為什麼要用神經網路呢?大家回顧一下,一個分類任務,我們在用機器學習演算法來做時,首先要明確feature和label,然後把這個數據"灌"到演算法里去訓練,最後保存模型,再來預測分類的準確性。但是這就有個問題,即我們需要實現確定好特徵,每一個特徵即為一個維度,特徵數目過少,我們可能無法精確的分類出來,即我們所說的欠擬合,如果特徵數目過多,可能會導致我們在分類過程中過於注重某個特徵導致分類錯誤,即過擬合。

  舉個簡單的例子,現在有一堆數據集,讓我們分類出西瓜和冬瓜,如果只有兩個特徵:形狀和顏色,可能沒法分區來;如果特徵的維度有:形狀、顏色、瓜瓤顏色、瓜皮的花紋等等,可能很容易分類出來;如果我們的特徵是:形狀、顏色、瓜瓤顏色、瓜皮花紋、瓜蒂、瓜籽的數量,瓜籽的顏色、瓜籽的大小、瓜籽的分布情況、瓜籽的XXX等等,很有可能會過擬合,譬如有的冬瓜的瓜籽數量和西瓜的類似,模型訓練後這類特徵的權重較高,就很容易分錯。這就導致我們在特徵工程上需要花很多時間和精力,才能使模型訓練得到一個好的效果。然而神經網路的出現使我們不需要做大量的特徵工程,譬如提前設計好特徵的內容或者說特徵的數量等等,我們可以直接把數據灌進去,讓它自己訓練,自我「修正」,即可得到一個較好的效果。

  • 數據格式的簡易性

  在一個傳統的機器學習分類問題中,我們「灌」進去的數據是不能直接灌進去的,需要對數據進行一些處理,譬如量綱的歸一化,格式的轉化等等,不過在神經網路里我們不需要額外的對數據做過多的處理,具體原因可以看後面的詳細推導。

  • 參數數目的少量性

  在面對一個分類問題時,如果用SVM來做,我們需要調整的參數需要調整核函數,懲罰因子,鬆弛變數等等,不同的參數組合對於模型的效果也不一樣,想要迅速而又準確的調到最適合模型的參數需要對背後理論知識的深入了解(當然,如果你說全部都試一遍也是可以的,但是花的時間可能會更多),對於一個基本的三層神經網路來說(輸入-隱含-輸出),我們只需要初始化時給每一個神經元上隨機的賦予一個權重w和偏置項b,在訓練過程中,這兩個參數會不斷的修正,調整到最優質,使模型的誤差最小。所以從這個角度來看,我們對於調參的背後理論知識並不需要過於精通(只不過做多了之後可能會有一些經驗,在初始值時賦予的值更科學,收斂的更快罷了)

有哪些應用?

  應用非常廣,不過大家注意一點,我們現在所說的神經網路,並不能稱之為深度學習,神經網路很早就出現了,只不過現在因為不斷的加深了網路層,複雜化了網路結構,才成為深度學習,並在圖像識別、圖像檢測、語音識別等等方面取得了不錯的效果。

基本網路結構

  一個神經網路最簡單的結構包括輸入層、隱含層和輸出層,每一層網路有多個神經元,上一層的神經元通過激活函數映射到下一層神經元,每個神經元之間有相對應的權值,輸出即為我們的分類類別。

詳細數學推導

  去年中旬我參考吳恩達的UFLDL和mattmazur的博客寫了篇文章詳細講解了一個最簡單的神經網路從前向傳播到反向傳播的直觀推導,大家可以先看看這篇文章--一文弄懂神經網路中的反向傳播法--BackPropagation。

優缺點

  前面說了很多優點,這裡就不多說了,簡單說說缺點吧。我們試想一下如果加深我們的網路層,每一個網路層增加神經元的數量,那麼參數的個數將是M*N(m為網路層數,N為每層神經元個數),所需的參數會非常多,參數一多,模型就複雜了,越是複雜的模型就越不好調參,也越容易過擬合。此外我們從神經網路的反向傳播的過程來看,梯度在反向傳播時,不斷的迭代會導致梯度越來越小,即梯度消失的情況,梯度一旦趨於0,那麼權值就無法更新,這個神經元相當於是不起作用了,也就很難導致收斂。尤其是在圖像領域,用最基本的神經網路,是不太合適的。後面我們會詳細講講為啥不合適。

為什麼要用卷積神經網路?

傳統神經網路的劣勢

  前面說到在圖像領域,用傳統的神經網路並不合適。我們知道,圖像是由一個個像素點構成,每個像素點有三個通道,分別代表RGB顏色,那麼,如果一個圖像的尺寸是(28,28,1),即代表這個圖像的是一個長寬均為28,channel為1的圖像(channel也叫depth,此處1代表灰色圖像)。如果使用全連接的網路結構,即,網路中的神經與與相鄰層上的每個神經元均連接,那就意味著我們的網路有28 * 28 =784個神經元,hidden層採用了15個神經元,那麼簡單計算一下,我們需要的參數個數(w和b)就有:784*15*10+2=117602個,這個參數太多了,隨便進行一次反向傳播計算量都是巨大的,從計算資源和調參的角度都不建議用傳統的神經網路。(評論中有同學對這個參數計算不太理解,我簡單說一下:圖片是由像素點組成的,用矩陣表示的,28*28的矩陣,肯定是沒法直接放到神經元里的,我們得把它「拍平」,變成一個28*28=784 的一列向量,這一列向量和隱含層的15個神經元連接,就有784*15=11760個權重w,隱含層和最後的輸出層的10個神經元連接,就有11760*10=117600個權重w,再加上輸入層和隱含層的兩個偏置項b,就是117602個參數了)

圖1 三層神經網路識別手寫數字

卷積神經網路是什麼?

三個基本層

  • 卷積層(Convolutional Layer)

  上文提到我們用傳統的三層神經網路需要大量的參數,原因在於每個神經元都和相鄰層的神經元相連接,但是思考一下,這種連接方式是必須的嗎?全連接層的方式對於圖像數據來說似乎顯得不這麼友好,因為圖像本身具有「二維空間特徵」,通俗點說就是局部特性。譬如我們看一張貓的圖片,可能看到貓的眼鏡或者嘴巴就知道這是張貓片,而不需要說每個部分都看完了才知道,啊,原來這個是貓啊。所以如果我們可以用某種方式對一張圖片的某個典型特徵識別,那麼這張圖片的類別也就知道了。這個時候就產生了卷積的概念。舉個例子,現在有一個4*4的圖像,我們設計兩個卷積核,看看運用卷積核後圖片會變成什麼樣。

圖2 4*4 image與兩個2*2的卷積核操作結果

  由上圖可以看到,原始圖片是一張灰度圖片,每個位置表示的是像素值,0表示白色,1表示黑色,(0,1)區間的數值表示灰色。對於這個4*4的圖像,我們採用兩個2*2的卷積核來計算。設定步長為1,即每次以2*2的固定窗口往右滑動一個單位。以第一個卷積核filter1為例,計算過程如下:

feature_map1(1,1) = 1*1 + 0*(-1) + 1*1 + 1*(-1) = 1

feature_map1(1,2) = 0*1 + 1*(-1) + 1*1 + 1*(-1) = -1

```

feature_map1(3,3) = 1*1 + 0*(-1) + 1*1 + 0*(-1) = 2

 可以看到這就是最簡單的內積公式。feature_map1(1,1)表示在通過第一個卷積核計算完後得到的feature_map的第一行第一列的值,隨著卷積核的窗口不斷的滑動,我們可以計算出一個3*3的feature_map1;同理可以計算通過第二個卷積核進行卷積運算後的feature_map2,那麼這一層卷積操作就完成了。feature_map尺寸計算公式:[ (原圖片尺寸 -卷積核尺寸)/ 步長 ] + 1。這一層我們設定了兩個2*2的卷積核,在paddlepaddle里是這樣定義的:

conv_pool_1 = paddle.networks.simple_img_conv_pool

input=img,

filter_size=3,

num_filters=2,

num_channel=1,

pool_stride=1,

act=paddle.activation.Relu())

 這裡調用了networks里simple_img_conv_pool函數,激活函數是Relu(修正線性單元),我們來看一看源碼里外層介面是如何定義的:

def simple_img_conv_pool(input, filter_size, num_filters, pool_size, name=None, pool_type=None, act=None, groups=1, conv_stride=1,

conv_padding=0,

bias_attr=None,

num_channel=None,

param_attr=None,

shared_bias=True,

conv_layer_attr=None,

pool_stride=1,

pool_padding=0,

pool_layer_attr=None):

"""

Simple image convolution and pooling group.

Img input => Conv => Pooling => Output.

:param name: group name.

:type name: basestring

:param input: input layer.

:type input: LayerOutput

:param filter_size: see img_conv_layer for details.

:type filter_size: int

:param num_filters: see img_conv_layer for details.

:type num_filters: int

:param pool_size: see img_pool_layer for details.

:type pool_size: int

:param pool_type: see img_pool_layer for details.

:type pool_type: BasePoolingType

:param act: see img_conv_layer for details.

:type act: BaseActivation

:param groups: see img_conv_layer for details.

:type groups: int

:param conv_stride: see img_conv_layer for details.

:type conv_stride: int

:param conv_padding: see img_conv_layer for details.

:type conv_padding: int

:param bias_attr: see img_conv_layer for details.

:type bias_attr: ParameterAttribute

:param num_channel: see img_conv_layer for details.

:type num_channel: int

:param param_attr: see img_conv_layer for details.

:type param_attr: ParameterAttribute

:param shared_bias: see img_conv_layer for details.

:type shared_bias: bool

:param conv_layer_attr: see img_conv_layer for details.

:type conv_layer_attr: ExtraLayerAttribute

:param pool_stride: see img_pool_layer for details.

:type pool_stride: int

:param pool_padding: see img_pool_layer for details.

:type pool_padding: int

:param pool_layer_attr: see img_pool_layer for details.

:type pool_layer_attr: ExtraLayerAttribute

:return: layers output

:rtype: LayerOutput

"""61 _conv_ = img_conv_layer(

name="%s_conv" % name,

input=input,

filter_size=filter_size,

num_filters=num_filters,

num_channels=num_channel,

act=act,

groups=groups,

stride=conv_stride,

padding=conv_padding,

bias_attr=bias_attr, param_attr=param_attr,

shared_biases=shared_bias,

layer_attr=conv_layer_attr)

return img_pool_layer(

name="%s_pool" % name,

input=_conv_,

pool_size=pool_size,

pool_type=pool_type,

stride=pool_stride,

padding=pool_padding,

layer_attr=pool_layer_attr)

我們在:Paddle/python/paddle/v2/framework/nets.py 里可以看到simple_img_conv_pool這個函數的定義

def simple_img_conv_pool(input, num_filters,

filter_size, pool_size, pool_stride,

act, pool_type=max, main_program=None, startup_program=None):

conv_out = layers.conv2d(

input=input,

num_filters=num_filters,

filter_size=filter_size,

act=act,

main_program=main_program,

startup_program=startup_program)

pool_out = layers.pool2d(

input=conv_out

pool_size=pool_size,

pool_type=pool_type,

pool_stride=pool_stride,

main_program=main_program,

startup_program=startup_program)

return pool_out

  可以看到這裡面有兩個輸出,conv_out是卷積輸出值,pool_out是池化輸出值,最後只返回池化輸出的值。conv_out和pool_out分別又調用了layers.py的conv2d和pool2d,去layers.py里我們可以看到conv2d和pool2d是如何實現的:

  conv2d:

def conv2d(input,

num_filters,

name=None,

filter_size=[1, 1],

act=None,

groups=None,

stride=[1, 1],

padding=None,

bias_attr=None,

param_attr=None,

main_program=None,

startup_program=None):

helper = LayerHelper(conv2d, **locals())

dtype = helper.input_dtype()

num_channels = input.shape[1]

if groups is None:

num_filter_channels = num_channels

else:

if num_channels % groups is not 0:

raise ValueError("num_channels must be divisible by groups.")

num_filter_channels = num_channels / groups

if isinstance(filter_size, int):

filter_size = [filter_size, filter_size]

if isinstance(stride, int):

stride = [stride, stride]

if isinstance(padding, int):

padding = [padding, padding]

input_shape = input.shape

filter_shape = [num_filters, num_filter_channels] + filter_size

std = (2.0 / (filter_size[0]**2 * num_channels))**0.5

filter = helper.create_parameter(

attr=helper.param_attr,

shape=filter_shape,

dtype=dtype,

initializer=NormalInitializer(0.0, std, 0))

pre_bias = helper.create_tmp_variable(dtype)

helper.append_op(

type=conv2d,

inputs={

Input: input,

Filter: filter,

},

outputs={"Output": pre_bias},

attrs={strides: stride,

paddings: padding,

groups: groups})

pre_act = helper.append_bias_op(pre_bias, 1)

return helper.append_activation(pre_act)

  pool2d:

def pool2d(input,

pool_size,

pool_type,

pool_stride=[1, 1],

pool_padding=[0, 0],

global_pooling=False,

main_program=None,

startup_program=None):

if pool_type not in ["max", "avg"]:

raise ValueError(

"Unknown pool_type: %s. It can only be max or avg.",

str(pool_type))

if isinstance(pool_size, int):

pool_size = [pool_size, pool_size]

if isinstance(pool_stride, int):

pool_stride = [pool_stride, pool_stride]

if isinstance(pool_padding, int):

pool_padding = [pool_padding, pool_padding]

helper = LayerHelper(pool2d, **locals())

dtype = helper.input_dtype()

pool_out = helper.create_tmp_variable(dtype)

helper.append_op(

type="pool2d",

inputs={"X": input},

outputs={"Out": pool_out},

attrs={

"poolingType": pool_type,

"ksize": pool_size,

"globalPooling": global_pooling,

"strides": pool_stride,

"paddings": pool_padding

})

return pool_out

  大家可以看到,具體的實現方式還調用了layers_helper.py:

import copy

import itertools

from paddle.v2.framework.framework import Variable, g_main_program,

g_startup_program, unique_name, Program

from paddle.v2.framework.initializer import ConstantInitializer,

UniformInitializer

class LayerHelper(object):

def __init__(self, layer_type, **kwargs):

self.kwargs = kwargs

self.layer_type = layer_type

name = self.kwargs.get(name, None)

if name is None:

self.kwargs[name] = unique_name(self.layer_type)

@property

def name(self):

return self.kwargs[name]

@property

def main_program(self):

prog = self.kwargs.get(main_program, None)

if prog is None:

return g_main_program

else:

return prog

@property

def startup_program(self):

prog = self.kwargs.get(startup_program, None)

if prog is None:

return g_startup_program

else:

return prog

def append_op(self, *args, **kwargs):

return self.main_program.current_block().append_op(*args, **kwargs)

def multiple_input(self, input_param_name=input):

inputs = self.kwargs.get(input_param_name, [])

type_error = TypeError(

"Input of {0} layer should be Variable or sequence of Variable".

format(self.layer_type))

if isinstance(inputs, Variable):

inputs = [inputs]

elif not isinstance(inputs, list) and not isinstance(inputs, tuple):

raise type_error

else:

for each in inputs:

if not isinstance(each, Variable):

raise type_error

return inputs

def input(self, input_param_name=input):

inputs = self.multiple_input(input_param_name)

if len(inputs) != 1:

raise "{0} layer only takes one input".format(self.layer_type)

return inputs[0]

@property

def param_attr(self):

default = {name: None, initializer: UniformInitializer()}

actual = self.kwargs.get(param_attr, None)

if actual is None:

actual = default

for default_field in default.keys():

if default_field not in actual:

actual[default_field] = default[default_field]

return actual

def bias_attr(self):

default = {name: None, initializer: ConstantInitializer()}

bias_attr = self.kwargs.get(bias_attr, None)

if bias_attr is True:

bias_attr = default

if isinstance(bias_attr, dict):

for default_field in default.keys():

if default_field not in bias_attr:

bias_attr[default_field] = default[default_field]

return bias_attr

def multiple_param_attr(self, length):

param_attr = self.param_attr

if isinstance(param_attr, dict):

param_attr = [param_attr]

if len(param_attr) != 1 and len(param_attr) != length:

raise ValueError("parameter number mismatch")

elif len(param_attr) == 1 and length != 1:

tmp = [None] * length

for i in xrange(length):

tmp[i] = copy.deepcopy(param_attr[0])

param_attr = tmp

return param_attr

def iter_inputs_and_params(self, input_param_name=input):

inputs = self.multiple_input(input_param_name)

param_attrs = self.multiple_param_attr(len(inputs))

for ipt, param_attr in itertools.izip(inputs, param_attrs):

yield ipt, param_attr

def input_dtype(self, input_param_name=input):

inputs = self.multiple_input(input_param_name)

dtype = None

for each in inputs:

if dtype is None:

dtype = each.data_type

elif dtype != each.data_type:

raise ValueError("Data Type mismatch")

return dtype

def create_parameter(self, attr, shape, dtype, suffix=w,

initializer=None):

# Deepcopy the attr so that parameters can be shared in program

attr_copy = copy.deepcopy(attr)

if initializer is not None:

attr_copy[initializer] = initializer

if attr_copy[name] is None:

attr_copy[name] = unique_name(".".join([self.name, suffix]))

self.startup_program.global_block().create_parameter(

dtype=dtype, shape=shape, **attr_copy)

return self.main_program.global_block().create_parameter(

name=attr_copy[name], dtype=dtype, shape=shape)

def create_tmp_variable(self, dtype):

return self.main_program.current_block().create_var(

name=unique_name(".".join([self.name, tmp])),

dtype=dtype,

persistable=False)

def create_variable(self, *args, **kwargs):

return self.main_program.current_block().create_var(*args, **kwargs)

def create_global_variable(self, persistable=False, *args, **kwargs):

return self.main_program.global_block().create_var(

*args, persistable=persistable, **kwargs)

def set_variable_initializer(self, var, initializer):

assert isinstance(var, Variable)

self.startup_program.global_block().create_var(

name=var.name,

type=var.type,

dtype=var.data_type,

shape=var.shape,

persistable=True,

initializer=initializer)

def append_bias_op(self, input_var, num_flatten_dims=None):

"""

Append bias operator and return its output. If the user does not set

bias_attr, append_bias_op will return input_var

:param input_var: the input variable. The len(input_var.shape) is larger

or equal than 2.

:param num_flatten_dims: The input tensor will be flatten as a matrix

when adding bias.

`matrix.shape = product(input_var.shape[0:num_flatten_dims]), product(

input_var.shape[num_flatten_dims:])`

"""

if num_flatten_dims is None:

num_flatten_dims = self.kwargs.get(num_flatten_dims, None)

if num_flatten_dims is None:

num_flatten_dims = 1

size = list(input_var.shape[num_flatten_dims:])

bias_attr = self.bias_attr()

if not bias_attr:

return input_var

b = self.create_parameter(

attr=bias_attr, shape=size, dtype=input_var.data_type, suffix=b)

tmp = self.create_tmp_variable(dtype=input_var.data_type)

self.append_op(

type=elementwise_add,

inputs={X: [input_var],

Y: [b]},

outputs={Out: [tmp]})

return tmp

def append_activation(self, input_var):

act = self.kwargs.get(act, None)

if act is None:

return input_var

if isinstance(act, basestring):

act = {type: act}

tmp = self.create_tmp_variable(dtype=input_var.data_type)

act_type = act.pop(type)

self.append_op(

type=act_type,

inputs={"X": [input_var]},

outputs={"Y": [tmp]},

attrs=act)

return tmp

  詳細的源碼細節我們下一節會講這裡指寫一下實現的方式和調用的函數。

  所以這個卷積過程就完成了。從上文的計算中我們可以看到,同一層的神經元可以共享卷積核,那麼對於高位數據的處理將會變得非常簡單。並且使用卷積核後圖片的尺寸變小,方便後續計算,並且我們不需要手動去選取特徵,只用設計好卷積核的尺寸,數量和滑動的步長就可以讓它自己去訓練了,省時又省力啊。

為什麼卷積核有效?

  那麼問題來了,雖然我們知道了卷積核是如何計算的,但是為什麼使用卷積核計算後分類效果要由於普通的神經網路呢?我們仔細來看一下上面計算的結果。通過第一個卷積核計算後的feature_map是一個三維數據,在第三列的絕對值最大,說明原始圖片上對應的地方有一條垂直方向的特徵,即像素數值變化較大;而通過第二個卷積核計算後,第三列的數值為0,第二行的數值絕對值最大,說明原始圖片上對應的地方有一條水平方向的特徵。

  仔細思考一下,這個時候,我們設計的兩個卷積核分別能夠提取,或者說檢測出原始圖片的特定的特徵。此時我們其實就可以把卷積核就理解為特徵提取器啊!現在就明白了,為什麼我們只需要把圖片數據灌進去,設計好卷積核的尺寸、數量和滑動的步長就可以讓自動提取出圖片的某些特徵,從而達到分類的效果啊!

  註:1.此處的卷積運算是兩個卷積核大小的矩陣的內積運算,不是矩陣乘法。即相同位置的數字相乘再相加求和。不要弄混淆了。

    2.卷積核的公式有很多,這只是最簡單的一種。我們所說的卷積核在數字信號處理里也叫濾波器,那濾波器的種類就多了,均值濾波器,高斯濾波器,拉普拉斯濾波器等等,不過,不管是什麼濾波器,都只是一種數學運算,無非就是計算更複雜一點。

3.每一層的卷積核大小和個數可以自己定義,不過一般情況下,根據實驗得到的經驗來看,會在越靠近輸入層的卷積層設定少量的卷積核,越往後,卷積層設定的卷積核數目就越多。具體原因大家可以先思考一下,小結里會解釋原因。

  • 池化層(Pooling Layer)

  通過上一層2*2的卷積核操作後,我們將原始圖像由4*4的尺寸變為了3*3的一個新的圖片。池化層的主要目的是通過降採樣的方式,在不影響圖像質量的情況下,壓縮圖片,減少參數。簡單來說,假設現在設定池化層採用MaxPooling,大小為2*2,步長為1,取每個窗口最大的數值重新,那麼圖片的尺寸就會由3*3變為2*2:(3-2)+1=2。從上例來看,會有如下變換:

 圖3 Max Pooling結果

通常來說,池化方法一般有一下兩種:

  • MaxPooling:取滑動窗口裡最大的值
  • AveragePooling:取滑動窗口內所有值的平均值

為什麼採用Max Pooling?

  從計算方式來看,算是最簡單的一種了,取max即可,但是這也引發一個思考,為什麼需要Max Pooling,意義在哪裡?如果我們只取最大值,那其他的值被捨棄難道就沒有影響嗎?不會損失這部分信息嗎?如果認為這些信息是可損失的,那麼是否意味著我們在進行卷積操作後仍然產生了一些不必要的冗餘信息呢?

  其實從上文分析卷積核為什麼有效的原因來看,每一個卷積核可以看做一個特徵提取器,不同的卷積核負責提取不同的特徵,我們例子中設計的第一個卷積核能夠提取出「垂直」方向的特徵,第二個卷積核能夠提取出「水平」方向的特徵,那麼我們對其進行Max Pooling操作後,提取出的是真正能夠識別特徵的數值,其餘被捨棄的數值,對於我提取特定的特徵並沒有特別大的幫助。那麼在進行後續計算使,減小了feature map的尺寸,從而減少參數,達到減小計算量,缺不損失效果的情況。

  不過並不是所有情況Max Pooling的效果都很好,有時候有些周邊信息也會對某個特定特徵的識別產生一定效果,那麼這個時候捨棄這部分「不重要」的信息,就不划算了。所以具體情況得具體分析,如果加了Max Pooling後效果反而變差了,不如把卷積後不加Max Pooling的結果與卷積後加了Max Pooling的結果輸出對比一下,看看Max Pooling是否對卷積核提取特徵起了反效果。

Zero Padding

所以到現在為止,我們的圖片由4*4,通過卷積層變為3*3,再通過池化層變化2*2,如果我們再添加層,那麼圖片豈不是會越變越小?這個時候我們就會引出「Zero Padding」(補零),它可以幫助我們保證每次經過卷積或池化輸出後圖片的大小不變,如,上述例子我們如果加入Zero Padding,再採用3*3的卷積核,那麼變換後的圖片尺寸與原圖片尺寸相同,如下圖所示:

 圖4 zero padding結果

  通常情況下,我們希望圖片做完卷積操作後保持圖片大小不變,所以我們一般會選擇尺寸為3*3的卷積核和1的zero padding,或者5*5的卷積核與2的zero padding,這樣通過計算後,可以保留圖片的原始尺寸。那麼加入zero padding後的feature_map尺寸 =( width + 2 * padding_size - filter_size )/stride + 1

  註:這裡的width也可換成height,此處是默認正方形的卷積核,weight = height,如果兩者不相等,可以分開計算,分別補零。

  • Flatten層 & Fully Connected Layer

  到這一步,其實我們的一個完整的「卷積部分」就算完成了,如果想要疊加層數,一般也是疊加「Conv-MaxPooing",通過不斷的設計卷積核的尺寸,數量,提取更多的特徵,最後識別不同類別的物體。做完Max Pooling後,我們就會把這些數據「拍平」,丟到Flatten層,然後把Flatten層的output放到full connected Layer里,採用softmax對其進行分類。

圖5 Flatten過程

  • 小結

  這一節我們介紹了最基本的卷積神經網路的基本層的定義,計算方式和起的作用。有幾個小問題可以供大家思考一下: 

1.卷積核的尺寸必須為正方形嗎?可以為長方形嗎?如果是長方形應該怎麼計算?

2.卷積核的個數如何確定?每一層的卷積核的個數都是相同的嗎?

3.步長的向右和向下移動的幅度必須是一樣的嗎?

  如果對上面的講解真的弄懂了的話,其實這幾個問題並不難回答。下面給出我的想法,可以作為參考:

  1.卷積核的尺寸不一定非得為正方形。長方形也可以,只不過通常情況下為正方形。如果要設置為長方形,那麼首先得保證這層的輸出形狀是整數,不能是小數。如果你的圖像是邊長為 28 的正方形。那麼卷積層的輸出就滿足 [ (28 - kernel_size)/ stride ] + 1 ,這個數值得是整數才行,否則沒有物理意義。譬如,你算得一個邊長為 3.6 的 feature map 是沒有物理意義的。 pooling 層同理。FC 層的輸出形狀總是滿足整數,其唯一的要求就是整個訓練過程中 FC 層的輸入得是定長的。如果你的圖像不是正方形。那麼在製作數據時,可以縮放到統一大小(非正方形),再使用非正方形的 kernel_size 來使得卷積層的輸出依然是整數。總之,撇開網路結果設定的好壞不談,其本質上就是在做算術應用題:如何使得各層的輸出是整數。

  2.由經驗確定。通常情況下,靠近輸入的卷積層,譬如第一層卷積層,會找出一些共性的特徵,如手寫數字識別中第一層我們設定卷積核個數為5個,一般是找出諸如"橫線"、「豎線」、「斜線」等共性特徵,我們稱之為basic feature,經過max pooling後,在第二層卷積層,設定卷積核個數為20個,可以找出一些相對複雜的特徵,如「橫折」、「左半圓」、「右半圓」等特徵,越往後,卷積核設定的數目越多,越能體現label的特徵就越細緻,就越容易分類出來,打個比方,如果你想分類出「0」的數字,你看到

這個特徵,能推測是什麼數字呢?只有越往後,檢測識別的特徵越多,試過能識別

這幾個特徵,那麼我就能夠確定這個數字是「0」。

3.有stride_w和stride_h,後者表示的就是上下步長。如果用stride,則表示stride_h=stride_w=stride。


手寫數字識別的CNN網路結構

  上面我們了解了卷積神經網路的基本結構後,現在來具體看一下在實際數據---手寫數字識別中是如何操作的。上文中我定義了一個最基本的CNN網路。如下(代碼詳見github)

def convolutional_neural_network_org(img): # first conv layer conv_pool_1 = paddle.networks.simple_img_conv_pool( input=img, filter_size=3, num_filters=20, num_channel=1, pool_size=2, pool_stride=2,

act=paddle.activation.Relu())

# second conv layer

conv_pool_2 = paddle.networks.simple_img_conv_pool(13 input=conv_pool_1,

filter_size=5,

num_filters=50,

num_channel=20,

pool_size=2,

pool_stride=2,

act=paddle.activation.Relu())

# fully-connected layer

predict = paddle.layer.fc(

input=conv_pool_2, size=10, act=paddle.activation.Softmax())

return predict

那麼它的網路結構是:

conv1----> conv2---->fully Connected layer

非常簡單的網路結構。第一層我們採取的是3*3的正方形卷積核,個數為20個,深度為1,stride為2,pooling尺寸為2*2,激活函數採取的為RELU;第二層只對卷積核的尺寸、個數和深度做了些變化,分別為5*5,50個和20;最後鏈接一層全連接,設定10個label作為輸出,採用Softmax函數作為分類器,輸出每個label的概率。

  那麼這個時候我考慮的問題是,既然上面我們已經了解了卷積核,改變卷積核的大小是否會對我的結果造成影響?增多卷積核的數目能夠提高準確率?於是我做了個實驗:

  • 第一次改進:僅改變第一層與第二層的卷積核數目的大小,其他保持不變。可以看到結果提升了0.06%
  •  第二次改進:保持3*3的卷積核大小,僅改變第二層的卷積核數目,其他保持不變,可以看到結果相較於原始參數提升了0.08%

  由以上結果可以看出,改變卷積核的大小與卷積核的數目會對結果產生一定影響,在目前手寫數字識別的項目中,縮小卷積核尺寸,增加卷積核數目都會提高準確率。不過以上實驗只是一個小測試,有興趣的同學可以多做幾次實驗,看看參數帶來的具體影響,下篇文章我們會著重分析參數的影響。

  這篇文章主要介紹了神經網路的預備知識,卷積神經網路的常見的層及基本的計算過程,看完後希望大家明白以下幾個知識點:

  • 為什麼卷積神經網路更適合於圖像分類?相比於傳統的神經網路優勢在哪裡?
  • 卷積層中的卷積過程是如何計算的?為什麼卷積核是有效的?
  • 卷積核的個數如何確定?應該選擇多大的卷積核對於模型來說才是有效的?尺寸必須為正方形嗎?如果是長方形因該怎麼做?
  • 步長的大小會對模型的效果產生什麼樣的影響?垂直方向和水平方向的步長是否得設定為相同的?
  • 為什麼要採用池化層,Max Pooling有什麼好處?
  • Zero Padding有什麼作用?如果已知一個feature map的尺寸,如何確定zero padding的數目?

上面的問題,有些在文章中已經詳細講過,有些大家可以根據文章的內容多思考一下。最後給大家留幾個問題思考一下:

  • 為什麼改變卷積核的大小能夠提高結果的準確率?卷積核大小對於分類結果是如何影響的?
  • 卷積核的參數是怎麼求的?一開始隨機定義一個,那麼後來是如何訓練才能使這個卷積核識別某些特定的特徵呢?
  • 1*1的卷積核有意義嗎?為什麼有些網路層結構里會採用1*1的卷積核?

作者:胡曉曼 Python愛好者社區專欄作者,請勿轉載,謝謝。

博客專欄:CharlotteDataMining的博客專欄

配套視頻教程:三個月教你從零入門深度學習!| 深度學習精華實踐課程

公眾號:Python愛好者社區(微信ID:python_shequ),關注,查看更多連載內容。

推薦閱讀:

TAG:深度學習DeepLearning | 卷積神經網路CNN | 機器學習 |