卷积神经网络究竟做了什么?

2018-04-19 20:04:52 浏览数 (1)

神经学习的一种主要方式就是卷积神经网络(CNN),有许多种方法去描述CNN到底做了什么,一般通过图像分类例子通过数学的或直观的方法来介绍如何训练和使用CNN。

作为一名程序员,我很高兴用C 等语言处理浮点数据,但我更倾向于使用预训练模型构建应用程序,而不是从头训练。假设我有一个预先训练好的图像分类器,我用它对一幅图像进行分类(“告诉我这是否是猪,牛或羊”) - 在代码上如何体现?

我将通过一个小型手写的C 卷积神经网络的形式来演示一个示例,其中只包含“执行”代码,不包含训练逻辑。它将使用来自Keras中类似模型的预先训练的数据,这些数据会在稍后发布。

我使用的具体例子是一个典型的图像分类:识别五种花中的哪一种在图像中。 代码Github

网络,层,权重,训练

在这种情况下的网络是一个通过数据传输的函数管道,每个函数的输出直接传递到下一个函数的输入。 这些功能中的每一个都称为一层(layer)。

首先将图像数据作为输入提供给第一层,最后一层返回一个包含五个数字的数组,表示每一种花的可能性。

因此,要进行一个分类,我们只需要将图像数据转换为正确的格式,并依次通过每个层的函数,然后显示网络的结果。

每一层会对数据进行数学运算。它们可能以某种方式与输入相乘(在这种情况下,它们被称为权重)或者添加到返回值(称为偏差)。

为这些层选择合适的权重和偏差就是训练的目的。权重和偏差一开始是随机初始化的,然后不断输入样本进行训练;结果与输入的类别进行比较,并根据学习率来进行权值和偏差的更新。如果幸运的话,这些值最终会收敛。

这些可训练的层一般夹在其它层之间,比如数据处理层(例如池化层)和提供非线性变换的层(也称为激活函数)。这些功能很单一:具有给定权重和偏差的层将始终为给定输入生成相同的输出,对经典的卷积神经网络来说是这样。

手绘网络手绘网络

这个小型网络包含四个卷积层,四个最大池化层,两个全连接层。

我从Tensorflow网站上下载了花卉数据集,使用基于Tensorflow的Keras(Python)构建和训练网络。Github中的obtain-data.sh用来下载数据集,with-keras/train.py用来训练模型并把训练后的权重输出到C 文件中。

然后我们C 重新写了这个模型(flower.cpp ),不使用任何神经学习库。weights_ 或biases_为开头的变量是从Keras中导出的训练好的值。它们的定义在weights.cpp中。

代码语言:txt复制
vector<float>
classify(const vector<vector<vector<float>>> &image)
{
    vector<vector<vector<float>>> t;

    t = zeroPad(image, 1, 1);
    t = convolve(t, weights_firstConv, biases_firstConv);
    t = activation(t, "relu");
    t = maxPool(t, 2, 2);

    t = zeroPad(t, 1, 1);
    t = convolve(t, weights_secondConv, biases_secondConv);
    t = activation(t, "relu");
    t = maxPool(t, 2, 2);

    t = zeroPad(t, 1, 1);
    t = convolve(t, weights_thirdConv, biases_thirdConv);
    t = activation(t, "relu");
    t = maxPool(t, 2, 2);

    t = zeroPad(t, 1, 1);
    t = convolve(t, weights_fourthConv, biases_fourthConv);
    t = activation(t, "relu");
    t = maxPool(t, 2, 2);

    vector<float> flat = flatten(t);
    
    flat = dense(flat, weights_firstDense, biases_firstDense);
    flat = activation(flat, "relu");

    flat = dense(flat, weights_labeller, biases_labeller);
    flat = activation(flat, "softmax");

    return flat;
}

许多函数都在不止一层中被使用。例如,convolve 函数被四个层使用(每个层的权重,输入形状,偏差都不同)。能这样重用是因为每一层的处理是流水线而不是状态转移。

我们把图像作为输入传入第一层,随后获得函数返回值传入下一层。这些值得类型是嵌套浮点向量。

它们都是张量的变体形式,我可以稍微讨论一下:

张量

就我们的目的而言,张量是一个多维数组,矢量和矩阵是其中的特殊情况。张量具有形状(我们先不用维度的概念)。

例子:

  • 矩阵是一个2阶张量,形状包括高度和宽度。 begin{bmatrix}1.0&3.0&5.0\2.0&4.0&6.0end{bmatrix} 的形状(shape)为2,3。
  • C 的浮点数向量是1阶张量,其形状是一个值的列表,即向量中元素的数量。 矢量{1.0,2.0,3.0}的形状为3。
  • 单个数字也可以被认为是0阶张量,其形状为[]。

在我们的网络中传递的所有值都是各种形状的张量。例如,彩色图像将被表示为等级3的张量,因为它具有高度,宽度和多个颜色通道(channel)。

在代码中,我们使用C Vector存储1阶张量,vector<vector<> >存储2阶张量;等等。 这使得索引变得容易,并且允许我们直接从它的类型中看到每个张量的阶。

专业的C 框架不是这样做的 - 它们通常将张量存储为单个大数组中的张量,知道如何进行索引。 有了这样的设计,所有张量将具有相同的C 类型,而不管它们的阶如何。

张量指数的排序存在一个问题。 了解张量的形状是不够的:我们也必须知道哪个索引是哪个属性。例如,如果我们按照高度,宽度和颜色通道编制索引,则128像素正方形的RGB图像的形状为128,128,3;按照颜色来编制索引就是3,128,128 。不幸的是,这两种都是常用的。 Keras通过一个函数,可以使用channels_last作为布局;Tensorflow中使用channels_first,因为它与GPU并行性更好。 本例中的代码使用channels_last排序。

张量的这个定义对我们来说已经足够了,但是在数学中,张量不仅仅是一个数列,而是一个在代数空间中的对象,并且可以以该空间进行操纵。而我们在这里不予考虑。

模型中的层

每个图层函数都需要一个张量作为输入。训练好的层还需要包含层权重和偏差的张量。

卷积层(Convolution layer)

这里显示了其核心代码,其余部分在convolve函数中实现。

代码语言:txt复制
for (size_t k = 0; k < nkernels;   k) {
    for (size_t y = 0; y < out_height;   y) {
        for (size_t x = 0; x < out_width;   x) {
            for (size_t c = 0; c < depth;   c) {
                for (size_t ky = 0; ky < kernel_height;   ky) {
                    for (size_t kx = 0; kx < kernel_width;   kx) {
                        out[y][x][k]  =
                            weights[ky][kx][c][k] *
                                in[y   ky][x   kx][c];
                    }
                }
            }
            out[y][x][k]  = biases[k];
        }
    }
}

七个嵌套for循环! 多么暴力的方法。

Every filter is small spatially (along width and height), but extends through the full depth of the input volume.

意思是,每次卷积操作在“一小块儿面积,包括全部深度”上进行的。假如这一层输入的维度是32*32*3,卷积核的维度是5*5*3(这里,5*5两个维度可以随意设计,但是3是固定的,因为输入数据的第三维度的值是3),那么得到的输入应该是28*28*1的。

这一层的权重是由卷积核(滤波器)定义的四阶张量组成的。每一个卷积核是三阶张量,宽-高-深。对于每一个输入的像素以及每一个颜色深度通道,根据卷积核的对应值乘以对应的像素值,然后将其相加成单个值,该值出现在输出中的对应位置。因此,我们得到一个输出张量,其中包含与输入图像(几乎)相同大小的矩阵。

也就是说,有多少种卷积核就能学到几种特征,卷积核可以捕获诸如颜色、边缘、线条等特征。

通常,图像分类器中的第一层卷积层将使参数量爆炸。(例如 32*32*3的图片,用一个5*5*3卷积核卷积,得到28*28*1的参数;用10个卷积核卷积,就能得到28*28*10的参数,几乎3倍于原来图像)

我在上面说过,输出矩阵几乎与输入一样大小。 它们其实必须要小一点,否则卷积核会超出边界。 为了做到这一点,我们在卷积层之前加上一个...

零填充层(Zero-padding layer)

在zeroPad 函数中可以看到定义。它创建一个新的且略大于输入的张量(多的地方用0填充):

代码语言:javascript复制
for (size_t y = 0; y < in_height;   y) {
    for (size_t x = 0; x < in_width;   x) {
        for (size_t c = 0; c < depth;   c) {
            out[y   pad_y][x   pad_x][c] = in[y][x][c];
        }
    }
}

在每个图像的所有四个边缘周围添加一个零值边框,以便使图像足够大,保证卷积后的输出和输入一样大。

在许多神经学习的函数中,如Keras,可以指定在卷积时是否进行填充的参数,而不用多加一个函数。我这样做是为了能更加清晰的表示其过程。

激活层(Activation layer)

这一般是训练中的一个函数,但我已经将其分开为一个层以简化问题。

通过对传递给它的张量中的每个值(独立地)应用一些简单的数学函数进行非线性转换。

历史上,对于没有卷积层的网络来说,激活函数通常是Sigmoid函数,常被用作神经网络的阈值函数,将变量映射到0,1之间。

卷积层之后的激活功能更可能是简单的整流器。 这给了我们所有机器学习中最令人失望的首字母缩略词:ReLU,它代表“整流线性单元(rectified linear unit)”。

代码语言:c复制
if (x < 0.0) {
    x = 0.0;
}

网络末端使用另一个激活函数:softmax。这是一个归一化函数,用于产生最终分类估计值,其类似于总计为1的概率:

代码语言:javascript复制
float sum = 0.f;
for (size_t i = 0; i < sz;   i) {
    out[i] = exp(out[i]);
    sum  = out[i];
}
for (size_t i = 0; i < sz;   i) {
    out[i] /= sum;
}

最大池化层(Max pooling layer)

第一层卷积层扩大了网络参数,随后的层将其缩小到更有意义并且参数更少。最大池化层的功能就是这样。它通过仅取每个N×M像素块中的最大值来降低输入的分辨率。对于我们网络,N和M都是2。

maxPool函数:

代码语言:javascript复制
for (size_t y = 0; y < out_height;   y) {
    for (size_t x = 0; x < out_width;   x) {
        for (size_t i = 0; i < pool_y;   i) {
            for (size_t j = 0; j < pool_x;   j) {
                for (size_t c = 0; c < depth;   c) {
                    float value = in[y * pool_y   i][x * pool_x   j][c];
                    out[y][x][c] = max(out[y][x][c], value);
                }
            }
        }
    }
}

扁平层(Flatten layer)

将三阶张量压缩为一阶张量(向量):

代码语言:javascript复制
for (size_t y = 0; y < height;   y) {
    for (size_t x = 0; x < width;   x) {
        for (size_t c = 0; c < depth;   c) {
            out[i  ] = in[y][x][c];
        }
    }
}

为什么要这样?因为这是全连接层希望得到的输入。我们希望简化那些高阶张量,得到单一的特征而不是一个复杂的特征。

实际的一些库函数操作只是改变张量的名称,是没有操作的。(其实就是把3阶张量的每一阶按顺序排好,这样就是一阶的了)

全连接层(Dense layer)

全连接层也可以叫稠密层(dense layer)。它由单个矩阵乘法组成,将输入向量乘以学习权重矩阵,然后添加偏差值。

我们的网络有两层全连接层,第二层产生最终的预测值。

dense函数:

代码语言:javascript复制
vector<float> out(out_size, 0.f);

for (size_t i = 0; i < in_size;   i) {
    for (size_t j = 0; j < out_size;   j) {
        out[j]  = weights[i][j] * in[i];
    }
}

for (size_t j = 0; j < out_size;   j) {
    out[j]  = biases[j];
}

这几乎就是全部的代码了。 每个函数都有一些模板,还有一些额外的代码使用libpng加载图像文件。

在Keras还有另外一中层,dropout层。我们的代码中没有这一层,因为它只在训练时使用。 它丢弃了输入传递给它的一部分值,这可以帮助后续层在训练时不会过拟合。

其他

精确性和再现性

训练网络是一个随机的过程。 给定的模型架构可以在单独的训练运行中产生完全不同的结果。

只有全部硬软件和数据集全部一样的情况下,同样的模型才能产生同样的结果。如果你用不同的库或框架,就算模型是一样的,结果可能只是相近或者有可能是错误的。(使用32位、64位对浮点精度产生的影响也会产生不同的结果)

对通道(channel)排序的不同方法可能会导致错误,尤其是把代码从一个框架移植到另外一个。

我应该在生产环境中使用这样的代码吗?

最好不要!

首先,这不是一个高效的层次结构。把零填充和激活函数分开为单独的层意味着需要更多的内存消耗和拷贝操作。

第二,有很多方法可以显着加速暴力层(即卷积层和全连接层层),即使在没有GPU支持的仅CPU的实现中,也可以使用矢量化和缓存和内存管理来加速。

我调整了示例网络,使用tiny-dnn的C 库,它的运行速度大约是我们代码速度的两倍(需要更少的时间)。我的代码写的不好,因为我要把channels-last转为channels-first。

第三,模型的可扩展性很差,如果你要扩展网络,模型实现要修改,单独加载权值的函数也要改。为此,使用支持自动导入和导出模型的库似乎是有意义的。

综上,我们的代码只是一个例子,能够得出结果。而不是高效、稳定的架构。

0 人点赞