動機
卷積神經網絡是一種特殊的MLP,這個概念是從生物里面演化過來的. 根據Hubel和Wiesel早期在貓的視覺皮層上的工作 [Hubel68], 我們知道在視覺皮層上面存在一種細胞的復雜分布,這些細胞對一些局部輸入是很敏感的,它們被成為感知野, 並通過這種特殊的組合方式來覆蓋整個視野. 這些過濾器對輸入空間是局部敏感的,因此能夠更好得發覺自然圖像中不同物體的空間相關性.
進一步講, 視覺皮層存在兩類不同的細胞,簡單細胞S和復雜細胞C. 簡單細胞盡可能得可視野中特殊的類似邊緣這種結構進行相應.復雜細胞具有更大的感知范圍,它們可以對刺激的空間位置進行精確的定位.
作為已知的最強大的視覺系統,視覺皮層也成為了科學研究的對象. 很多神經科學中提出的模型,都是基於對其進行的研究,比如, NeoCognitron [Fukushima], HMAX [Serre07] 以及本文討論的重點 LeNet-5 [LeCun98]
稀疏連接性
CNN通過增強相鄰兩層中神經元的局部的連接來發掘局部空間相關性. m層的隱輸入單元和m-1層的一部分空間相鄰,並具有連續可視野的神經元相連接. 它們的關系如下圖所示:

具體細節
從概念上講,特征圖通過對輸入圖像在一個線性濾波器上的卷積運算,增加一個便宜量,在結果上作用一個非線性函數得到.如果我們把某層的第k個的特征圖記為$h^k$,其過濾器由權重$W$和偏移量$b_k$決定, 那么,特征圖可以通過下面的函數得到:
$$h^k_{ij} = \tanh ( (W^k * x)_{ij} + b_k ).$$
為了更好的表達數據, 隱層由一系列的多個特征圖構成${h^{(k)}, k= 0 .. K}$. 其權重$W$由四個參數決定: 目標特征圖的索引,源特征圖的索引,源水平位置索引和源垂直位置索引. 偏移量為一個向量,其中每一個元素對應目標特征圖的一個索引. 其邏輯關系通過下圖表示:
Figure 1: 卷積層實例 (這個圖和下面的說明有點沖突,下面的特征權重表示成了$W^0$,$W^1$,圖中是 $W^1$,$W^2$)
這里是一個兩層的CNN,它有 m-1層的四個特征圖和m層的兩個特征圖($h^0, h^1$)構成. 神經元在$h^0$和$h^1$的輸出(藍色和紅色的框所示)是由m-1層落入其相應的2*2的可視野的像素計算得到, 這里需要注意可視野如何地跨四個特征圖.其權重為3D張量,分別表示了輸入特征圖的索引,以及像素的坐標.
整合以上概念, $W_{ij}^{kl}$表示了連接m層第k個特征圖的特征圖上每一個像素的權重, 像素為m-1層的第l個特征圖,其位置為 $(i,j)$.
ConvOp
Convop是Theano中實現卷積的函數, 它主要重復了scipy工具包中signal.convolve2d的函數功能. 總的來講,ConvOp包含兩個參數:
- 對應輸入圖像的mini-batch的4D張量. 其每個張量的大小為:[mini-batch的大小, 輸入的特征圖的數量, 圖像的高度,圖像的寬度]
- 對應權重矩陣$W$的4D張量,其每個張量的大小為:[m層的特征圖的數量,m-1層的特征圖的數量,過濾器的高度,過濾器的寬度].
下面的代碼實現了一個類似圖1里面的卷積層. 輸入圖像包括大小為120*160的三個特征圖(對應RGB). 我們可以用兩個具有9*9的可視野的卷積過濾器.
from theano.tensor.nnet import conv rng = numpy.random.RandomState(23455) # instantiate 4D tensor for input input = T.tensor4(name='input') # initialize shared variable for weights. w_shp = (2, 3, 9, 9) w_bound = numpy.sqrt(3 * 9 * 9) W = theano.shared( numpy.asarray( rng.uniform( low=-1.0 / w_bound, high=1.0 / w_bound, size=w_shp), dtype=input.dtype), name ='W') # initialize shared variable for bias (1D tensor) with random values # IMPORTANT: biases are usually initialized to zero. However in this # particular application, we simply apply the convolutional layer to # an image without learning the parameters. We therefore initialize # them to random values to "simulate" learning. b_shp = (2,) b = theano.shared(numpy.asarray( rng.uniform(low=-.5, high=.5, size=b_shp), dtype=input.dtype), name ='b') # build symbolic expression that computes the convolution of input with filters in w conv_out = conv.conv2d(input, W) # build symbolic expression to add bias and apply activation function, i.e. produce neural net layer output # A few words on ``dimshuffle`` : # ``dimshuffle`` is a powerful tool in reshaping a tensor; # what it allows you to do is to shuffle dimension around # but also to insert new ones along which the tensor will be # broadcastable; # dimshuffle('x', 2, 'x', 0, 1) # This will work on 3d tensors with no broadcastable # dimensions. The first dimension will be broadcastable, # then we will have the third dimension of the input tensor as # the second of the resulting tensor, etc. If the tensor has # shape (20, 30, 40), the resulting tensor will have dimensions # (1, 40, 1, 20, 30). (AxBxC tensor is mapped to 1xCx1xAxB tensor) # More examples: # dimshuffle('x') -> make a 0d (scalar) into a 1d vector # dimshuffle(0, 1) -> identity # dimshuffle(1, 0) -> inverts the first and second dimensions # dimshuffle('x', 0) -> make a row out of a 1d vector (N to 1xN) # dimshuffle(0, 'x') -> make a column out of a 1d vector (N to Nx1) # dimshuffle(2, 0, 1) -> AxBxC to CxAxB # dimshuffle(0, 'x', 1) -> AxB to Ax1xB # dimshuffle(1, 'x', 0) -> AxB to Bx1xA output = T.nnet.sigmoid(conv_out + b.dimshuffle('x', 0, 'x', 'x')) # create theano function to compute filtered images f = theano.function([input], output)
首先我們用得到的函數f做點有意思的事情.
import pylab from PIL import Image # open random image of dimensions 639x516 img = Image.open(open('images/3wolfmoon.jpg')) img = numpy.asarray(img, dtype='float64') / 256. # put image in 4D tensor of shape (1, 3, height, width) img_ = img.swapaxes(0, 2).swapaxes(1, 2).reshape(1, 3, 639, 516) filtered_img = f(img_) # plot original image and first and second components of output pylab.subplot(1, 3, 1); pylab.axis('off'); pylab.imshow(img) pylab.gray(); # recall that the convOp output (filtered image) is actually a "minibatch", # of size 1 here, so we take index 0 in the first dimension: pylab.subplot(1, 3, 2); pylab.axis('off'); pylab.imshow(filtered_img[0, 0, :, :]) pylab.subplot(1, 3, 3); pylab.axis('off'); pylab.imshow(filtered_img[0, 1, :, :]) pylab.show()
運行代碼,可以得到如下結果:
我們可以注意到,隨機初始化的濾波器能夠產生邊緣檢測算子的作用。另外,我們用和MLP中相同的權重對公式進行初始化。這些權重是從均勻分布[-1/fan-in, 1/fan-in]隨機采樣得到的。這里 fan-in是輸入層到隱層單元的數量。對於MLP來說,這正是下一層的單元的數目。而對於CNNs,我們需要考慮到輸入特征圖的數量,以及可視野的大小。
共用最大化
CNN的另外一個重要特征是共用最大化,這其實是一種非線性向下采樣的方法。共用最大化把輸入圖像分割成不重疊的矩形,然后對於每個矩形區域,輸出最大化的結果。
這個技術在視覺上的好處主要有兩個方面 (1)它降低了上層的計算復雜度 (2)它提供了一種變換不變量的。對於第二種益處,我們可以假設把一個共用最大化層和一個卷積層組合起來,對於單個像素,輸入圖像可以有8個方向的變換。如果共有最大層在2*2的窗口上面實現,這8個可能的配置中,有3個可以准確的產生和卷積層相同的結果。如果窗口變成3*3,則產生精確結果的概率變成了5/8.
可見,共有最大化對位置信息提供了附加的魯棒性,它以一種非常聰明的方式減少了中間表示的維度。
在Theano中,這種技術通過函數 theano.tensor.signal.downsample.max_pool_2d 實現,這個函數的輸入是一個N維張量(N>2), 和一個縮放因子來對這個張量進行共用最大化的變換。下面的例子說明了這個過程:
from theano.tensor.signal import downsample input = T.dtensor4('input') maxpool_shape = (2, 2) pool_out = downsample.max_pool_2d(input, maxpool_shape, ignore_border=True) f = theano.function([input],pool_out) invals = numpy.random.RandomState(1).rand(3, 2, 5, 5) print 'With ignore_border set to True:' print 'invals[0, 0, :, :] =\n', invals[0, 0, :, :] print 'output[0, 0, :, :] =\n', f(invals)[0, 0, :, :] pool_out = downsample.max_pool_2d(input, maxpool_shape, ignore_border=False) f = theano.function([input],pool_out) print 'With ignore_border set to False:' print 'invals[1, 0, :, :] =\n ', invals[1, 0, :, :] print 'output[1, 0, :, :] =\n ', f(invals)[1, 0, :, :]
這段代碼的輸出為類似下面的內容:
With ignore_border set to True: invals[0, 0, :, :] = [[ 4.17022005e-01 7.20324493e-01 1.14374817e-04 3.02332573e-01 1.46755891e-01] [ 9.23385948e-02 1.86260211e-01 3.45560727e-01 3.96767474e-01 5.38816734e-01] [ 4.19194514e-01 6.85219500e-01 2.04452250e-01 8.78117436e-01 2.73875932e-02] [ 6.70467510e-01 4.17304802e-01 5.58689828e-01 1.40386939e-01 1.98101489e-01] [ 8.00744569e-01 9.68261576e-01 3.13424178e-01 6.92322616e-01 8.76389152e-01]] output[0, 0, :, :] = [[ 0.72032449 0.39676747] [ 0.6852195 0.87811744]] With ignore_border set to False: invals[1, 0, :, :] = [[ 0.01936696 0.67883553 0.21162812 0.26554666 0.49157316] [ 0.05336255 0.57411761 0.14672857 0.58930554 0.69975836] [ 0.10233443 0.41405599 0.69440016 0.41417927 0.04995346] [ 0.53589641 0.66379465 0.51488911 0.94459476 0.58655504] [ 0.90340192 0.1374747 0.13927635 0.80739129 0.39767684]] output[1, 0, :, :] = [[ 0.67883553 0.58930554 0.69975836] [ 0.66379465 0.94459476 0.58655504] [ 0.90340192 0.80739129 0.39767684]]
注意到和大部分代碼不同的是,這個函數max_pool_2d 在創建Theano圖的時候,需要一個向下采樣的因子ds (長度為2的tuple變量,表示了圖像的寬和高的縮放. 這個可能在以后的版本中升級。
LeNet模型
稀疏,卷積層和共有最大化是LeNet的核心概念。因為模型的細節會有很大的變換,我們用下面的圖來詮釋LeNet的模型。

模型的低層由卷積和共有最大化層組成,高層是全連接的一個MLP 神經網絡,它包含了隱層和對數回歸。高層的輸入是下層特征圖的結合。
從實現的角度講,這意味着低層操作了4D的張量,這個張量被壓縮到了一個2D矩陣表示的光柵化的特征圖上,以便於和前面的MLP的實現兼容。
綜合所有
現在我們有了實現LeNet模型的所有細節,我們創建一個LeNetConvPoolLayer類,用了表示一個卷積和共有最大化層:
class LeNetConvPoolLayer(object): def __init__(self, rng, input, filter_shape, image_shape, poolsize=(2, 2)): """ Allocate a LeNetConvPoolLayer with shared variable internal parameters. :type rng: numpy.random.RandomState :param rng: a random number generator used to initialize weights :type input: theano.tensor.dtensor4 :param input: symbolic image tensor, of shape image_shape :type filter_shape: tuple or list of length 4 :param filter_shape: (number of filters, num input feature maps, filter height,filter width) :type image_shape: tuple or list of length 4 :param image_shape: (batch size, num input feature maps, image height, image width) :type poolsize: tuple or list of length 2 :param poolsize: the downsampling (pooling) factor (#rows,#cols) """ assert image_shape[1] == filter_shape[1] self.input = input # initialize weight values: the fan-in of each hidden neuron is # restricted by the size of the receptive fields. fan_in = numpy.prod(filter_shape[1:]) W_values = numpy.asarray(rng.uniform( low=-numpy.sqrt(3./fan_in), high=numpy.sqrt(3./fan_in), size=filter_shape), dtype=theano.config.floatX) self.W = theano.shared(value=W_values, name='W') # the bias is a 1D tensor -- one bias per output feature map b_values = numpy.zeros((filter_shape[0],), dtype=theano.config.floatX) self.b = theano.shared(value=b_values, name='b') # convolve input feature maps with filters conv_out = conv.conv2d(input, self.W, filter_shape=filter_shape, image_shape=image_shape) # downsample each feature map individually, using maxpooling pooled_out = downsample.max_pool_2d(conv_out, poolsize, ignore_border=True) # add the bias term. Since the bias is a vector (1D array), we first # reshape it to a tensor of shape (1, n_filters, 1, 1). Each bias will thus # be broadcasted across mini-batches and feature map width & height self.output = T.tanh(pooled_out + self.b.dimshuffle('x', 0, 'x', 'x')) # store parameters of this layer self.params = [self.W, self.b]
應該注意的是,在初始化權重的時候,fan-in是由感知野的大小和輸入特征圖的數目決定的。
最后,采用前面章節定義的LogisticRegression和HiddenLayer類,LeNet就可以工作了。
class LeNetConvPoolLayer(object): def __init__(self, rng, input, filter_shape, image_shape, poolsize=(2, 2)): """ Allocate a LeNetConvPoolLayer with shared variable internal parameters. :type rng: numpy.random.RandomState :param rng: a random number generator used to initialize weights :type input: theano.tensor.dtensor4 :param input: symbolic image tensor, of shape image_shape :type filter_shape: tuple or list of length 4 :param filter_shape: (number of filters, num input feature maps, filter height,filter width) :type image_shape: tuple or list of length 4 :param image_shape: (batch size, num input feature maps, image height, image width) :type poolsize: tuple or list of length 2 :param poolsize: the downsampling (pooling) factor (#rows,#cols) """ assert image_shape[1] == filter_shape[1] self.input = input # initialize weight values: the fan-in of each hidden neuron is # restricted by the size of the receptive fields. fan_in = numpy.prod(filter_shape[1:]) W_values = numpy.asarray(rng.uniform( low=-numpy.sqrt(3./fan_in), high=numpy.sqrt(3./fan_in), size=filter_shape), dtype=theano.config.floatX) self.W = theano.shared(value=W_values, name='W') # the bias is a 1D tensor -- one bias per output feature map b_values = numpy.zeros((filter_shape[0],), dtype=theano.config.floatX) self.b = theano.shared(value=b_values, name='b') # convolve input feature maps with filters conv_out = conv.conv2d(input, self.W, filter_shape=filter_shape, image_shape=image_shape) # downsample each feature map individually, using maxpooling pooled_out = downsample.max_pool_2d(conv_out, poolsize, ignore_border=True) # add the bias term. Since the bias is a vector (1D array), we first # reshape it to a tensor of shape (1, n_filters, 1, 1). Each bias will thus # be broadcasted across mini-batches and feature map width & height self.output = T.tanh(pooled_out + self.b.dimshuffle('x', 0, 'x', 'x')) # store parameters of this layer self.params = [self.W, self.b]
這里我們忽略了具體的訓練和提前結束的代碼,這些代碼和前面MLP里面的是完全一樣的。感興趣的讀者可以查閱DeeplearningTutoirals下面code目錄的代碼。
運行算法
算法運行很簡單,通過一個命令:
python code/convolutional_mlp.py
下面的結果為在i7-2600K CPU的機器上面,采用默認參數和‘floatX=float32’的輸出
Optimization complete. Best validation score of 0.910000 % obtained at iteration 17800,with test performance 0.920000 % The code for file convolutional_mlp.py ran for 380.28m
在GeForce GTX 285的平台上面,結果略有不同
Optimization complete. Best validation score of 0.910000 % obtained at iteration 15500,with test performance 0.930000 % The code for file convolutional_mlp.py ran for 46.76m
結果中的細小差別來自於不同硬件下不同的圓整機制,這些差別可以忽略。