卷积神经网络 — 从0开始

之前的教程里,在输入神经网络前我们将输入图片直接转成了向量。这样做有两个不好的地方:

  • 在图片里相近的像素在向量表示里可能很远,从而模型很难捕获他们的空间关系。
  • 对于大图片输入,模型可能会很大。例如输入是\(256\times 256\times3\)的照片(仍然远比手机拍的小),输出层是1000,那么这一层的模型大小是将近1GB.

这一节我们介绍卷积神经网络,其有效了解决了上述两个问题。

卷积神经网络

卷积神经网络是指主要由卷积层构成的神经网络。

卷积层

卷积层跟前面的全连接层类似,但输入和权重不是做简单的矩阵乘法,而是使用每次作用在一个窗口上的卷积。下图演示了输入是一个\(4\times 4\)矩阵,使用一个\(3\times 3\)的权重,计算得到\(2\times 2\)结果的过程。每次我们采样一个跟权重一样大小的窗口,让它跟权重做按元素的乘法然后相加。通常我们也是用卷积的术语把这个权重叫kernel或者filter。

(图片版权属于vdumoulin@github)

我们使用nd.Convolution来演示这个。

In [1]:
from mxnet import nd

# 输入输出数据格式是 batch x channel x height x width,这里batch和channel都是1
# 权重格式是 output_channels x in_channels x height x width,这里input_filter和output_filter都是1。
w = nd.arange(4).reshape((1,1,2,2))
b = nd.array([1])
data = nd.arange(9).reshape((1,1,3,3))
out = nd.Convolution(data, w, b, kernel=w.shape[2:], num_filter=w.shape[1])

print('input:', data, '\n\nweight:', w, '\n\nbias:', b, '\n\noutput:', out)
input:
[[[[ 0.  1.  2.]
   [ 3.  4.  5.]
   [ 6.  7.  8.]]]]
<NDArray 1x1x3x3 @cpu(0)>

weight:
[[[[ 0.  1.]
   [ 2.  3.]]]]
<NDArray 1x1x2x2 @cpu(0)>

bias:
[ 1.]
<NDArray 1 @cpu(0)>

output:
[[[[ 20.  26.]
   [ 38.  44.]]]]
<NDArray 1x1x2x2 @cpu(0)>

我们可以控制如何移动窗口,和在边缘的时候如何填充窗口。下图演示了stride=2pad=1

In [2]:
out = nd.Convolution(data, w, b, kernel=w.shape[2:], num_filter=w.shape[1],
                     stride=(2,2), pad=(1,1))

print('input:', data, '\n\nweight:', w, '\n\nbias:', b, '\n\noutput:', out)
input:
[[[[ 0.  1.  2.]
   [ 3.  4.  5.]
   [ 6.  7.  8.]]]]
<NDArray 1x1x3x3 @cpu(0)>

weight:
[[[[ 0.  1.]
   [ 2.  3.]]]]
<NDArray 1x1x2x2 @cpu(0)>

bias:
[ 1.]
<NDArray 1 @cpu(0)>

output:
[[[[  1.   9.]
   [ 22.  44.]]]]
<NDArray 1x1x2x2 @cpu(0)>

当输入数据有多个通道的时候,每个通道会有对应的权重,然后会对每个通道做卷积之后在通道之间求和

\[conv(data, w, b) = \sum_i conv(data[:,i,:,:], w[:,i,:,:], b)\]
In [3]:
w = nd.arange(8).reshape((1,2,2,2))
data = nd.arange(18).reshape((1,2,3,3))

out = nd.Convolution(data, w, b, kernel=w.shape[2:], num_filter=w.shape[0])

print('input:', data, '\n\nweight:', w, '\n\nbias:', b, '\n\noutput:', out)
input:
[[[[  0.   1.   2.]
   [  3.   4.   5.]
   [  6.   7.   8.]]

  [[  9.  10.  11.]
   [ 12.  13.  14.]
   [ 15.  16.  17.]]]]
<NDArray 1x2x3x3 @cpu(0)>

weight:
[[[[ 0.  1.]
   [ 2.  3.]]

  [[ 4.  5.]
   [ 6.  7.]]]]
<NDArray 1x2x2x2 @cpu(0)>

bias:
[ 1.]
<NDArray 1 @cpu(0)>

output:
[[[[ 269.  297.]
   [ 353.  381.]]]]
<NDArray 1x1x2x2 @cpu(0)>

当输出需要多通道时,每个输出通道有对应权重,然后每个通道上做卷积。

\[conv(data, w, b)[:,i,:,:] = conv(data, w[i,:,:,:], b[i])\]
In [4]:
w = nd.arange(16).reshape((2,2,2,2))
data = nd.arange(18).reshape((1,2,3,3))
b = nd.array([1,2])

out = nd.Convolution(data, w, b, kernel=w.shape[2:], num_filter=w.shape[0])

print('input:', data, '\n\nweight:', w, '\n\nbias:', b, '\n\noutput:', out)
input:
[[[[  0.   1.   2.]
   [  3.   4.   5.]
   [  6.   7.   8.]]

  [[  9.  10.  11.]
   [ 12.  13.  14.]
   [ 15.  16.  17.]]]]
<NDArray 1x2x3x3 @cpu(0)>

weight:
[[[[  0.   1.]
   [  2.   3.]]

  [[  4.   5.]
   [  6.   7.]]]


 [[[  8.   9.]
   [ 10.  11.]]

  [[ 12.  13.]
   [ 14.  15.]]]]
<NDArray 2x2x2x2 @cpu(0)>

bias:
[ 1.  2.]
<NDArray 2 @cpu(0)>

output:
[[[[  269.   297.]
   [  353.   381.]]

  [[  686.   778.]
   [  962.  1054.]]]]
<NDArray 1x2x2x2 @cpu(0)>

池化层(pooling)

因为卷积层每次作用在一个窗口,它对位置很敏感。池化层能够很好的缓解这个问题。它跟卷积类似每次看一个小窗口,然后选出窗口里面最大的元素,或者平均元素作为输出。

In [5]:
data = nd.arange(18).reshape((1,2,3,3))

max_pool = nd.Pooling(data=data, pool_type="max", kernel=(2,2))
avg_pool = nd.Pooling(data=data, pool_type="avg", kernel=(2,2))

print('data:', data, '\n\nmax pooling:', max_pool, '\n\navg pooling:', avg_pool)
data:
[[[[  0.   1.   2.]
   [  3.   4.   5.]
   [  6.   7.   8.]]

  [[  9.  10.  11.]
   [ 12.  13.  14.]
   [ 15.  16.  17.]]]]
<NDArray 1x2x3x3 @cpu(0)>

max pooling:
[[[[  4.   5.]
   [  7.   8.]]

  [[ 13.  14.]
   [ 16.  17.]]]]
<NDArray 1x2x2x2 @cpu(0)>

avg pooling:
[[[[  2.   3.]
   [  5.   6.]]

  [[ 11.  12.]
   [ 14.  15.]]]]
<NDArray 1x2x2x2 @cpu(0)>

下面我们可以开始使用这些层构建模型了。

获取数据

我们继续使用FashionMNIST(希望你还没有彻底厌烦这个数据)

In [6]:
import sys
sys.path.append('..')
from utils import load_data_fashion_mnist

batch_size = 256
train_data, test_data = load_data_fashion_mnist(batch_size)

定义模型

因为卷积网络计算比全连接要复杂,这里我们默认使用GPU来计算。如果GPU不能用,默认使用CPU。(下面这段代码会保存在utils.py里可以下次重复使用)。

In [7]:
import mxnet as mx

try:
    ctx = mx.gpu()
    _ = nd.zeros((1,), ctx=ctx)
except:
    ctx = mx.cpu()
ctx
Out[7]:
gpu(0)

我们使用MNIST常用的LeNet,它有两个卷积层,之后是两个全连接层。注意到我们将权重全部创建在ctx上:

In [8]:
weight_scale = .01

# output channels = 20, kernel = (5,5)
W1 = nd.random_normal(shape=(20,1,5,5), scale=weight_scale, ctx=ctx)
b1 = nd.zeros(W1.shape[0], ctx=ctx)

# output channels = 50, kernel = (3,3)
W2 = nd.random_normal(shape=(50,20,3,3), scale=weight_scale, ctx=ctx)
b2 = nd.zeros(W2.shape[0], ctx=ctx)

# output dim = 128
W3 = nd.random_normal(shape=(1250, 128), scale=weight_scale, ctx=ctx)
b3 = nd.zeros(W3.shape[1], ctx=ctx)

# output dim = 10
W4 = nd.random_normal(shape=(W3.shape[1], 10), scale=weight_scale, ctx=ctx)
b4 = nd.zeros(W4.shape[1], ctx=ctx)

params = [W1, b1, W2, b2, W3, b3, W4, b4]
for param in params:
    param.attach_grad()

卷积模块通常是“卷积层-激活层-池化层”。然后转成2D矩阵输出给后面的全连接层。

In [9]:
def net(X, verbose=False):
    X = X.as_in_context(W1.context)
    # 第一层卷积
    h1_conv = nd.Convolution(
        data=X, weight=W1, bias=b1, kernel=W1.shape[2:], num_filter=W1.shape[0])
    h1_activation = nd.relu(h1_conv)
    h1 = nd.Pooling(
        data=h1_activation, pool_type="max", kernel=(2,2), stride=(2,2))
    # 第二层卷积
    h2_conv = nd.Convolution(
        data=h1, weight=W2, bias=b2, kernel=W2.shape[2:], num_filter=W2.shape[0])
    h2_activation = nd.relu(h2_conv)
    h2 = nd.Pooling(data=h2_activation, pool_type="max", kernel=(2,2), stride=(2,2))
    h2 = nd.flatten(h2)
    # 第一层全连接
    h3_linear = nd.dot(h2, W3) + b3
    h3 = nd.relu(h3_linear)
    # 第二层全连接
    h4_linear = nd.dot(h3, W4) + b4
    if verbose:
        print('1st conv block:', h1.shape)
        print('2nd conv block:', h2.shape)
        print('1st dense:', h3.shape)
        print('2nd dense:', h4_linear.shape)
        print('output:', h4_linear)
    return h4_linear

测试一下,输出中间结果形状(当然可以直接打印结果)和最终结果。

In [10]:
for data, _ in train_data:
    net(data, verbose=True)
    break
1st conv block: (256, 20, 12, 12)
2nd conv block: (256, 1250)
1st dense: (256, 128)
2nd dense: (256, 10)
output:
[[  6.16078833e-05   3.83485640e-05   3.61880448e-05 ...,   8.24655071e-05
    3.41616624e-06  -4.51478736e-05]
 [ -9.00142641e-06   1.49268981e-05   2.68087988e-05 ...,   1.12204383e-04
   -1.21336529e-06  -5.67077268e-06]
 [ -2.90407115e-05   4.11847577e-05   4.29721658e-05 ...,   1.52364155e-04
    4.09115200e-05  -5.53629843e-06]
 ...,
 [ -3.79942539e-05  -2.24201744e-06   1.86594152e-05 ...,   1.39625496e-04
    1.67154794e-05  -7.76401066e-05]
 [  1.59946885e-05   2.27384971e-05   9.11999232e-05 ...,   7.44563367e-05
    7.97327539e-06  -1.65979163e-05]
 [ -1.08626491e-05   5.06926772e-05   6.64876716e-05 ...,   1.30652406e-04
    1.19667811e-05   2.84869639e-05]]
<NDArray 256x10 @gpu(0)>

训练

跟前面没有什么不同的,除了这里我们使用as_in_contextdatalabel都放置在需要的设备上。(下面这段代码也将保存在utils.py里方便之后使用)。

In [11]:
from mxnet import autograd as autograd
from utils import SGD, accuracy, evaluate_accuracy
from mxnet import gluon

softmax_cross_entropy = gluon.loss.SoftmaxCrossEntropyLoss()

learning_rate = .2

for epoch in range(5):
    train_loss = 0.
    train_acc = 0.
    for data, label in train_data:
        label = label.as_in_context(ctx)
        with autograd.record():
            output = net(data)
            loss = softmax_cross_entropy(output, label)
        loss.backward()
        SGD(params, learning_rate/batch_size)

        train_loss += nd.mean(loss).asscalar()
        train_acc += accuracy(output, label)

    test_acc = evaluate_accuracy(test_data, net, ctx)
    print("Epoch %d. Loss: %f, Train acc %f, Test acc %f" % (
        epoch, train_loss/len(train_data),
        train_acc/len(train_data), test_acc))
Epoch 0. Loss: 2.302502, Train acc 0.104000, Test acc 0.109175
Epoch 1. Loss: 1.414779, Train acc 0.458868, Test acc 0.702324
Epoch 2. Loss: 0.674665, Train acc 0.739383, Test acc 0.758714
Epoch 3. Loss: 0.537602, Train acc 0.792134, Test acc 0.815805
Epoch 4. Loss: 0.466482, Train acc 0.825337, Test acc 0.854067

结论

可以看到卷积神经网络比前面的多层感知的分类精度更好。事实上,如果你看懂了这一章,那你基本知道了计算视觉里最重要的几个想法。LeNet早在90年代就提出来了。不管你相信不相信,如果你5年前懂了这个而且开了家公司,那么你很可能现在已经把公司作价几千万卖个某大公司了。幸运的是,或者不幸的是,现在的算法已经更加高级些了,接下来我们会看到一些更加新的想法。

练习

  • 试试改改卷积层设定,例如filter数量,kernel大小
  • 试试把池化层从max改到avg
  • 如果你有GPU,那么尝试用CPU来跑一下看看
  • 你可能注意到比前面的多层感知机慢了很多,那么尝试计算下这两个模型分别需要多少浮点计算。例如\(n\times m\)\(m \times k\)的矩阵乘法需要浮点运算 \(2nmk\)

吐槽和讨论欢迎点这里