TensorFlow 实战Google深度学习框架(第2版)第六章读书笔记

第六章:图像识别与卷积神经网络

* 6.1图像识别问题简介&经典数据集
* 6.2卷积神经网络简介
* 6.3卷积神经网络常用结构
       * 6.3.1卷积层
       * 6.3.2池化层
* 6.4经典卷积网络模型
       * 6.4.1LeNet-5模型
       * 6.4.2Inception-v3模型
* 6.5卷积神经网络迁移学习
       * 6.5.1迁移学习介绍
       * 6.5.2TensorFlow实现迁移学习

在第 5 章中,通过 MNIST 数据集验证了第 4 章介绍的神经网络设计与优化的方法。
从实验的结果可以看出,神经网络的结构会对神经网络的准确率产生巨大的影响。
本章将介 绍一个非常常用的神经网络结构 一一 卷积神经网络 (CNN: Convolutional Neural Network)
卷积神经网络的应用非常广泛,在自然语言处理、医药发现、灾难气候发现甚至围棋人工智能程序中都有应用。
本章将主要通过卷积神经网络在图像识别上的应用来讲解卷积神经网络的基本原理以及如何使用 TensorFlow 实现卷积神经网络 。

-6.1- 图像识别问题简介&经典数据集

本节中,将:
* 介绍图像识别领域中需要解决的问题
* 介绍图像识别领域中经典的数据集

视觉是人类认识世界非常重要的一种知觉。对于人类来说,通过视觉来识别手写体数 字、识别图片中的 物体或者找出图片中人脸的轮廓都是非常简单的任务。
然而对于计算机而言,让计算机识别图片中的内容就不是一件容易的事情了。图像识别问题希望借助计算机程序来处理、分析和理解图片中的内容,使得计算机可以从图片中自动识别各种不同模式的目标和对象。比如在第5章中介绍的MNIST数据集就是通过计算机来识别图片中的手写体数字。
图像识别问题作为人工智能的一个重要领域,在最近几年己经取得了很多突破性的进展。本章将要介绍的卷积神经网络就是这些突破性进展背后的最主要技术支持。

MNIST 数据集
图6-1

图中显示了图像识别的主流技术在 MNIST 数据集上的错误率随着年份的发展趋势图。
图6-1中最下方的虚线表示人工标注的错误率,其他不同的线段表示了不同算法的错误率。

从图6-1上可以看出,相比其他算法,卷积神经网络可以得到更低的错误率。
而且通过卷积神经网络达到的错误率已经非常接近人工标注的错误率了。
在MNIST数据集的一万个测试数据上,最好的深度学习算法只会比人工识别多错一张图片。

CIFAR 数据集

MNIST手写体识别数据集是一个相对简单的数据集,在其他更加复杂的图像识别数据集上,卷积神经网络有更加突出的表现。CIFAR数据集就是一个影响力很大的图像分类数据集。

CIFAR数据集分为了CIFAR-10CIFAR-100两个问题,它们都是图像词典项目(Visual Dictionary)中800万张图片的一个子集。
CIFAR数据集中的图片为32×32的彩色图片,这些图片是由Alex Krizhevsky教授、Vinod Nair博士和Geoffrey Hinton教授整理的。
CIFAR-10问题收集了来自10个不同种类的60000张图片。

图6-2

图6-2的左侧显示了CIFAR-10数据集中的每一个种类中的一些样例图片以及这些种类的类别名称 ,
图6-2的右侧给出CIFAR-10中一张飞机的图像。因为图像的像素仅为32×32,所以放大之后图片是比较模糊的,但隐约还是可以看出飞机的轮廓。

CIFAR官网https://www.cs.toronto.edu/~kriz/cifar.html提供了不同格式的CIFAR数据集下载,具体的数据格式这里不再赘述。

* 和MNIST数据集类似,CIFAR-10中的图片大小都是固定的且每一张图片中仅包含一个种类的实体。
* 但和MNIST相比,CIFAR数据集最大的区别在于图片由黑白变成的彩色,且分类的难度也相对更高。在CIFAR-10数据集上,人工标注的正确率大概为94%,这比MNIST数据集上的人工表现要低很多。

图6-3

图6-3给出了MNIST和CIFAR-10数据集中比较难以分类的图片样例。
在图6-3左侧的4张图片给出了CIFAR-10数据集中比较难分类的图片,直接从图片上看,人类也很难判断图片上实体的类别。
图6-3右侧的4张图片给出了MNIST数据集中难度较高的图片。在这些难度高的图片上,人类还是可以有一个比较准确的猜测。

目前在CIFAR-10数据集上最好的图像识别算法正确率为 95.59%
达到这个正确率的算法同样使用了卷积神经网络

ImageNet 数据集

无论是MNIST数据集还是CIFAR数据集,相比真实环境下的图像识别问题,有2个最大的问题。
* 第一,现实生活中的图片分辨率要远高于32×32,而且图像的分辨率也不会是同定的。
* 第二,现实生活中的物体类别很多,无论是10种还是100种都远远不够,而且一张图片中不会只出现一个种类的物体。
为了更加贴近真实环境下的图像识别问题,由斯坦福大学(Stanford University)的李飞飞(Feifei Li)教授带头整理的ImageNet很大程度地解决了这两个问题。
ImageNet是一个基于WordNet(大型英语语义网)的大型图像数据库。在ImageNet中,将近1500万图片被关联到了WordNet的大约20000个名词同义词集上。目前每一个与ImageNet相关的WordNet同义词集都代表了现实世界中的一个实体,可以被认为是分类问题中的一个类别。
ImageNet中的图片都是从互联网上爬取下来的,井且通过亚马逊的人工标注服务(Amazon Mechanical Turk)将图片分类到WordNet的同义词集上。在ImageNet的图片中,一张图片中可能出现多个同义词集所代表的实体。

图6-4

图6-4展示了ImageNet中的一张图片,在这张图片上用几个矩形框出了不同实体的轮廓。在物体识别问题中,一般将用于框出实体的矩形称为Bounding Box。在图6-4中总共可以找到4个实体,其中有两把椅子、一个人和一条狗。类似图6-4中所示,ImageNet的部分图片中的实体轮廓也被标注了出来,以用于更加精确的图像识别。

ImageNet每年都举办图像识别相关的竞赛ILSVRC(lmageNet Large Scale Visual Recognition Challenge),而且每年的竞赛都会有一些不同的问题,这些问题基本涵盖了图像识别的主要研究方向。ImageNet的官网http://www.image-net.org/challenges/LSVRC列出了历届ILSVRC竞赛的题目和数据集。不同年份的ImageNet比赛提供了不同的数据集,本书将着重介绍使用得最多的ILSVRC2012图像分类数据集。ILSVRC2012图像分类数据集的任务和CIFAR数据集是基本一致的,也是识别图像中的主要物体。ILSVRC2012图像分类数据集包含了来自1000个类别的120万张图片,其中每张图片属于且只属于一个类别。因为ILSVRC2012图像分类数据集中的图片是直接从互联网上爬取得到,所以图片的大小从几千字节到几百万字节不等。

图6-5

图6-5给出了不同算法在ImageNet图像分类数据集上的top-5正确率。top-N正确率指的是图像识别算法给出前N个答案中有一个是正确的概率。在图像分类问题上,很多学术论文都将前N个答案的正确率作为比较的方法,其中N的取值一般为3或5。
从图6-5中可以看出,在更加复杂的ImageNet问题上,基于卷积神经网络的图像识别算法可以远远超过人类的表现。
在图6-5的左侧对比了传统算法与深度学习算法的正确率。从图中可以看出,深度学习,特别是卷积神经网络,给图像识别问题带来了质的飞跃。2013年之后,基本上所有的研究都集中到了深度学习算法上。从6.2节开始将具体介绍卷积神经网络的基本原理,以及如何通过TensorFlow实现卷积神经网络。

-6.2- 卷积神经网络简介

本节中,将:
* 介绍卷积神经网络的主体思想
* 介绍卷积神经网络的整体架构

全连接网络 & 卷积网络

在6.1节中介绍图像识别问题时,已经多次提到了卷积神经网络。卷积神经网络在6.1节中介绍的所有图像分类数据集上有非常突出的表现。
在前面的章节中所介绍的神经网络每两层之间的所有结点都是有边相连的,所以本书称这种网络结构为全连接层网络结构。为了将只包含全连接层的神经网络与卷积神经网络循环神经网络区分开,本书将只包含全连接层的神经网络称之为全连接神经网络。在第4章和第5章中介绍的神经网络都为全连接神经网络。
在这一节中将讲解卷积神经网络与全连接神经网络的差异,并介绍组成一个卷积神经网络的基本网络结构。

图6-6

图6-6显示了全连接神经网络与卷积神经网络的结构对比图。
虽然图6-6中显示的全连接神经网络结构和卷积神经网络的结构直观上差异比较大,但实际上它们的整体架构是非常相似的。
从图6-6中可以看出,卷积神经网络也是通过一层一层的节点组织起来的。和全连接神经网络一样,卷积神经网络中的每一个节点都是一个神经元。
* 在全连接神经网络中,每相邻两层之间的节点有边相连,于是一般会将每一层全连接层中的节点组织成一列,这样方便显示连接结构。
* 而对于卷积神经网络,相邻两层之间只有部分节点相连,为了展示每一层神经元的维度,一般会将每一层卷积层的节点组织成一个三维矩阵。

除了结构相似,卷积神经网络的输入输出以及训练流程与全连接神经网络也基本一致。
以图像分类为例,卷积神经网络的输入层就是图像的原始像素,而输出层中的每一个节点代表了不同类别的可信度。这和全连接神经网络的输入输出是一致的。
类似的,第4章中介绍的损失函数以及参数的优化过程也都适用于卷积神经网络。
在后面的章节中会看到,在TensorFlow中训练一个卷积神经网络的流程和训练一个全连接神经网络没有任何区别。
卷积神经网络和全连接神经网络的唯一区别就在于神经网络中相邻两层的连接方式。

图像数据 & 模型参数

在进一步介绍卷积神经网络的连接结构之前,本节将先介绍为什么全连接神经网络无法很好地处理图像数据。
使用全连接神经网络处理图像的最大问题在于全连接层的参数太多
对于MNIST数据,每一张图片的大小是28×28×1,其中28×28为图片的大小,×1表示图像是黑白的,只有一个色彩通道。假设第一层隐藏层的节点数为500个,那么一个全连接层的神经网络将有28×28×500+500=392500个参数。
当图片更大时,比如在CIFAR-10数据集中,图片的大小为32×32×3,其中32×32表示图片的大小,×3表示图片是通过红绿蓝三个色彩通道(channel)表示的。这样输入层就有3072个节点,如果第一层全连接层仍然是500个节点,那么这一层全链接神经网络将有3072x500+500≈150万个参数。

参数增多除了导致计算速度减慢,还很容易导致过拟合问题。
所以需要一个更合理的神经网络结构来有效地减少神经网络中参数个数。
卷积神经网络就可以达到这个目的。

卷积神经网络模型架构
图6-7

图6-7给出了一个更加具体的卷积神经网络模型架构图。
在卷积神经网络的前几层中,每一层的节点都被组织成一个三维矩阵。比如处理CIFAR-10数据集中的图片时,可以将输入层组织成一个32×32维的三维矩阵。
图6-7中虚线部分展示了卷积神经网络的一个连接示意图,从图中可以看出卷积神经网络中前几层中每一个节点只和上一层中部分的节点相连。卷积神经网络的具体连接方式将在6.3节中介绍。
一个卷积神经网络模型架构主要由以下5种结构组成:
* 输入层
输入层是整个神经网络的输入,在处理图像的卷积神经网络中,它一般代表了一张图片的像素矩阵。比如在图6-7中,最左侧的三维矩阵就可以代表一张图片。其中三维矩阵的长和宽代表了图像的大小,而三维矩阵的深度代表了图像的色彩通道(channel)。比如黑白图片的深度为1,而在RGB色彩模式下,图像的深度为3。
从输入层开始,卷积神经网络模型通过不同的神经网络结构将上一层的三维矩阵转化为下一层的三维矩阵,直到最后的全连接层。

* 卷积层
从名字就可以看出,卷积层是一个卷积神经网络中最为重要的部分。和传统全连接层不同,卷积层中每一个节点的输入只是上一层神经网络的一小块,这个小块常用的大小有3×3或者5×5。卷积层试图将神经网络中的每一小块进行更加深入地分析从而得到抽象程度更高的特征。
一般来说,通过卷积层处理过的节点矩阵会变得更深,所以在图6-7中可以看到经过卷积层之后的节点矩阵的深度会增加。

* 池化层(Pooling)
池化层神经网络不会改变三维矩阵的深度,但是它可以缩小矩阵的大小。池化操作可以认为是将一张分辨率较高的图片转化为分辨率较低的图片。通过池化层,可以进一步缩小最后全连接层中节点的个数,从而达到减少整个神经网络中参数的目的。

* 全连接层
如图6-7所示,在经过多轮卷积层和池化层的处理之后,在卷积神经网络的最后一般会是由1到2个全连接层来给出最后的分类结果。
经过几轮卷积层和池化层的处理之后,可以认为图像中的信息已经被抽象成了信息含量更高的特征。我们可以将卷积层池化层看成自动图像特征提取的过程。
在特征提取完成之后,仍然需要使用全连接层来完成分类任务

* Softmax层
和第4章中介绍的一样,Softmax层主要用于分类问题。通过Softmax层,可以得到当前样例属于不同种类的概率分布情况。

在卷积神经网络中使用到的输入层、全连接层和Softmax层在第4章中都有过详细的介绍,这里不再赘述。在下面的6.3节中将详细介绍卷积神经网络中特殊的两个网络结构一一卷积层池化层

-6.3- 卷积神经网络常用结构

本节中,将:
* 介绍卷积层池化层的网络结构
* 介绍卷积层池化层的前向传播过程
* 通过TensorFlow实现卷积层池化层,(不介绍优化卷积神经网络的数学公式)

-6.3.1- 卷积层

本小节将详细介绍卷积层的结构以及其前向传播的算法

图6-8

图6-8中显示了卷积层神经网络结构中最重要的部分,这个部分被称之为过滤器(filter)或者内核(kernel)。因为TensorFlow文档中将这个结构称之为过滤器(filter),所以在本书中将统称这个结构为过滤器。
如图6-8所示,过滤器可以将当前层神经网络上的一个子节点矩阵转化为下一层神经网络上的一个单位节点矩阵。单位节点矩阵指的是一个长和宽都为1,但深度不限的节点矩阵。

filter/kernel的尺寸&深度

在一个卷积层中,过滤器所处理的节点矩阵的长和宽都是由人工指定的,这个节点矩阵的尺寸也被称之为过滤器的尺寸。常用的过滤器尺寸有3×3或5×5。因为过滤器处理的矩阵深度和当前层神经网络节点矩阵的深度是一致的,所以虽然节点矩阵是三维的,但过滤器的尺寸只需要指定两个维度。
过滤器中另外一个需要人工指定的设置是处理得到的单位节点矩阵的深度,这个设置称为过滤器的深度

注意过滤器的尺寸指的是一个过滤器输入节点矩阵的大小,
而深度指的是输出单位节点矩阵的深度。
如图6-8所示,左侧小矩阵的尺寸为过滤器的尺寸,而右侧单位矩阵的深度为过滤器的深度。
6.4节将通过一些经典卷积神经网络结构来了解如何设置每一层卷积层过滤器的尺寸和深度。

filter/kernel的前向传播过程

如图6-8所示,过滤器的前向传播过程就是通过左侧小矩阵中的节点计算出右侧单位矩阵中节点的过程。为了直观地解释过滤器的前向传播过程,在下面的篇幅中将给出一个具体的样例。在这个样例中将展示如何通过过滤器将一个2×2×3的节点矩阵变化为一个1×1×5的单位节点矩阵。一个过滤器的前向传播过程和全连接层相似,它总共需要个参数,其中最后的+5为偏置项参数的个数。
假设:
* 使用叫来表示对于输出单位节点矩阵中的第个节点,过滤器输入节点的权重,
* 使用表示第个输出节点对应的偏置项参数
那么:单位矩阵中,第个节点的取值为:

其中:
* 为激活函数
* a_{x,y,z}为filter节点的取值

图6-9

图6-9展示了在给定,和的情况下,使用ReLU作为激活函数时的计算过程。

* 在图6-9的左侧给出了和的取值,这里通过3个二维矩阵来表示一个三维矩阵的取值,其中每一个二维矩阵表示三维矩阵在某一个深度上的取值。
* 图6-9中·符号表示点积,也就是矩阵中对应元素乘积的和。
* 图6-9的右侧显示了的计算过程。如果给出 ~ 和 ~ ,那么也可以类似地计算出 ~ 的取值。

如果将和组织成两个向量,那么一个过滤器的计算过程完全可以通过第3章中介绍的向量乘法来完成。
以上样例已经介绍了在卷积层中计算一个过滤器的前向传播过程。卷积层结构的前向传播过程就是通过将一个过滤器从神经网络当前层的左上角移动到右下角,并且在移动中计算每一个对应的单位矩阵得到的。

图6-10

图6-10展示了卷积层结构前向传播的过程。为了更好地可视化过滤器的移动过程,图6-10中使用的节点矩阵深度都为1。在图6-10中,展示了在3×3矩阵上使用2×2过滤器的卷积层前向传播过程。在这个过程中,首先将这个过滤器用于左上角子矩阵,然后移动到右上角矩阵,再到左下角矩阵,最后到右下角矩阵。过滤器每移动一次,可以计算得到一个值(当深度为k时会计算出k个值)。将这些数值拼接成一个新的矩阵,就完成了卷积层前向传播的过程。图6-10的右侧显示了过滤器在移动过程中计算得到的结果与新矩阵中节点的对应关系。
当过滤器的大小不为1×1时,卷积层前向传播得到的矩阵的尺寸要小于当前层矩阵的尺寸。如图6-10所示,当前层矩阵的大小为3×3(图6-10左侧矩阵),而通过卷积层前向传播算法之后,得到的矩阵大小为2×2(图6-10右侧矩阵)。


6-11

为了避免尺寸的变化,可以在当前层矩阵的边界上加入全0填充(zero-padding)。这样可以使得卷积层前向传播结果矩阵的大小和当前层矩阵保持一致。图6-11显示了使用全0填充后卷积层前向传播过程示意图,从图中可以看出,加入一层全0填充后,得到的结构矩阵大小就为3×3了。

图6-12

除了使用全0填充,还可以通过设置过滤器移动的步长来调整结果矩阵的大小。在图6-10和图6-11中,过滤器每次都只移动一格。图6-12中显示了当移动步长为2且使用全0填充时,卷积层前向传播的过程。
从图6-12上可以看出,当长和宽的步长均为2时,过滤器每隔2步计算一次结果,所以得到的结果矩阵的长和宽也就都只有原来的一半。以下公式给出了在同时使用全0填充时结果矩阵的大小。


在图6-10、图6-11以及图6-12中,只讲解了移动过滤器的方式,没有涉及过滤器中的参数如何设定,所以在这些图片中结果矩阵中并没有填上具体的值。

filter/kernel的参数共享

在卷积神经网络中,每一个卷积层中使用的过滤器中的参数都是一样的。这是卷积神经网络一个非常重要的性质。
* 从直观上理解,共享过滤器的参数可以使得图像上的内容不受位置的影响。以MNIST手写体数字识别为例,无论数字“1”出现在左上角还是右下角,图片的种类都是不变的。因为在左上角和右下角使用的过滤器参数相同,所以通过卷积层之后无论数字在图像上的哪个位置,得到的结果都一样。
* 共享每一个卷积层の过滤器中的参数可以巨幅减少神经网络上的参数。以CIFAR-10问题为例,输入层矩阵的维度是32x32x3。假设第一层卷积层使用尺寸为5x5,深度为16的过滤器,那么这个卷积层的参数个数为个。6.2节中提到过,使用500个隐藏节点的全连接层将有1.5 百万个参数。相比之下,卷积层的参数个数要远远小于全连接层。而且卷积层的参数个数和图片的大小无关,它只和过滤器的尺寸、深度以及当前层节点矩阵的深度有关。这使得卷积神经网络可以很好地扩展到更大的图像数据上。

图6-13

结合过滤器的使用方法和参数共享的机制,图6-13给出了使用了全0填充、步长为2的卷积层前向传播的计算流程。图6-13给出了过滤器上权重的取值以及偏置项的取值,通过图6-9中所示的计算方法,可以得到每一个格子的具体取值。以下公式给出了左上角格子取值的计算方法,其他格子可以依次类推。

Tensorflow实现了卷积层的前向传播

TensorFlow 对卷积神经网络提供非常好的支持,以下程序实现了一个卷积层的前向传播过程。从以下代码可以看出,通过TensorFlow实现卷积层是非常方便的。

# 通过tf.get_variable的方式创建过滤器的权重变量和偏置项变量。
# 上面介绍了卷积层的参数个数只和过滤器的尺寸、深度以及当前层节点矩阵的深度有关
# 所以这里声明的参数变量是一个4维矩阵:
# * 前面两个维度代表了过滤器的尺寸
# * 第三个维度表示当前层的深度
# * 第四个维度表示过滤器的深度
filter_weight = tf.get_variables(
    'weight',
    [5, 5, 3, 16], 
    initializer=tf.truncated_normal_initializer(stddev=0.1)
)
# 和卷积层的权重类似,当前层矩阵上不同位置的偏置项也是共享的
# 所以总有[下一层深度个]不同的偏置项
# 本样例代码中,16为过滤器的深度,也是神经网络中下一层节点矩阵的深度
biases = tf.get_variable(
    'biases',
    [16],
    initializer=tf.constant_initializer(0.1)
)

# tf.nn.conv2d 提供了一个非常方便的函数来实现卷积层的前向传播算法。
# * tf.nn.conv2d函数的第一个输入为当前层的节点矩阵
#     注意,这个矩阵是一个四维矩阵
#     * 后面三个维度对应一个节点矩阵
#     * 第一维对应一个输入batch,比如在输入层,input[0,:,:,:]表示第一张图片input[1,:,:,:]表示第二张图片,以此类推
# * tf.nn.conv2d函数的第二个输入提供了卷积层的参数
# * tf.nn.conv2d函数的第三个输入为不同维度上的步长
#     虽然第三个参数提供的是一个长度为4的数组,但是第一维、第四维的数字要求一定是1
#     这是因为卷积kernel的步长只对图片的长、宽起作用,在batch、像素通道上的步长为1
# * tf.nn.conv2d函数的第四个输入是填充(padding)的方法
#     TensorFlow 中提供SAME或是VALID两种选择
#     * SAME表示添加全0填充(如图6-11所示)
#     * VALID表示不添加(如图6-10所示)
conv = tf.nn.connv2d(
    input,
    filter_weight,
    strides=[1,1,1,1],
    padding='SAME'
)

# tf.nn.bias_add提供了一个方便的函数给每一个节点添加上偏置项
# 注意⚠️这里不能直接用加法,因为矩阵上不同位置上的节点都要加上同样的偏置项
# 如图6-13所示,虽然下一层神经网络的大小为2×2,但是偏置项只有一个数(因为深度为1),而2×2矩阵中的每一个值都需要加上这个偏置项。
bias = tf.nn.bias_add(cone, biases)

# 将计算结果通过RELU完成去线性化
actived_conv = tf.nn.relu(bias)
-6.3.2- 池化层

6.2 节介绍过卷积神经网络的大致架构。
从图6-7中可以看出,在卷积层之间往往会加上一个池化层(pooling layer)。
本节将具体介绍池化层的结构及作用,
池化层可以非常有效地缩小矩阵的尺寸,从而减少最后全连接层中的参数。
使用池化层既可以加快计算速度,也有防止过拟合问题的作用。

和6.3.1节中介绍的卷积层类似,池化层前向传播的过程也是通过移动一个类似过滤器的结构完成的。不过池化层过滤器中的计算不是节点的加权和,而是采用更加简单的最大值或者平均值运算。使用最大值操作的池化层被称之为最大池化层(max pooling),这是被使用得最多的池化层结构。使用平均值操作的池化层被称之为平均池化层(average pooling)。其他池化层在实践中使用的比较少,本书不做过多的介绍。

与卷积层的过滤器类似,池化层的过滤器也需要人工设定过滤器的尺寸、是否使用全0填充以及过滤器移动的步长等设置,而且这些设置的意义也是一样的。卷积层和池化层中过滤器移动的方式是相似的,唯一的区别在于卷积层使用的过滤器是横跨整个深度的,而池化层使用的过滤器只影响一个深度上的节点。所以池化层的过滤器除了在长和宽两个维度移动,它还需要在深度这个维度移动。


图6-14

图6-14展示了一个最大池化层前向传播计算过程。在图6-14中,不同颜色或者不同线段(虚线或者实线)代表了不同的池化层过滤器。从图6-14中可以看出,池化层的过滤器除了在长和宽的维度上移动,它还需要在深度的维度上移动。

以下TensorFlow程序实现了最大池化层的前向传播算法:

# tf.nn.max_pool 实现了最大池化层的前向传播过程
# tf.nn.max_pool的参数和tf.nn.conv2d函数类似
# ksize提供过滤器的尺寸
# strides提供步长信息
# padding提供了是否使用全0填充
pool = tf.nn.max_pool(
    actived_conv,
    ksize=[1, 3, 3, 1], 
    strides=[1, 2, 2, 1],
    padding='SAME'
)

对比池化层和卷积层前向传播在tf中的实现,可以发现函数的参数形式是相似的。在tf.nn.max_pool()函数中:
* 首先需要传入当前层的节点矩阵,这个矩阵是一个四维矩阵,格式和tf.nn.conv2d()函数中的第一个参数一致。
* 第二个参数为过滤器的尺寸。虽然给出的是一个长度为4的一维数组,但是这个数组的第一个和最后一个数必须为1。这意味着池化层的过滤器是不可以跨不同输入样例或者节点矩阵深度的。在实际应用中使用得最多的池化层过滤器尺寸为[1, 2, 2, 1]或者[1, 3, 3, 1]。
* 第三个参数为步长,它和tf.nn.conv2d()函数中步长的意义是一样的,而且第一维和最后一维也只能为1。这意味着在TensorFlow中,池化层不能减少节点矩阵的深度或者输入样例的个数。
* 最后一个参数指定了是否使用全0填充。这个参数也只有两种取值:VALID或者SAME,其中VALID表示不使用全0填充,SAME表示使用全0填充。

TensorFlow还提供了tf.nn.avg_pool()来实现平均池化层。tf.nn.avg_pool函数的调用格式和tf.nn.max_pool()函数是一致的。

-6.4- 经典卷积网络模型

本节中,将:
* 介绍两个经典的卷积神经网络模型:LeNet-5 模型Inception-v3 模型
* 介绍如何设计卷积神经网络的架构,以及如何设置神经网络每一层的配置
* 介绍实现LeNet-5模型的完整的TensorFlow程序
* 介绍TensorFlow-Slim工具,来实现更加复杂的Inception-v3模型中的Inception模块

在6.3节中介绍了卷积神经网络特有的两种网络结构一一卷积层和池化层。
然而,通过这些网络结构任意组合得到的神经网络有无限多种,怎样的神经网络更有可能解决真实的图像处理问题呢?
这一节将介绍一些经典的卷积神经网络的网络结构。通过这些经典的卷积神经网络的网络结构可以总结出卷积神经网络结构设计的一些模式。

-6.4.1- LeNet-5模型

本节中将
* 具体介绍 LeNet-5 模型
* 并给出 一个完整的TensorFlow程序来实现LeNet-5模型。
* 通过这个模型,将给出卷积神经网络结构设计的一个通用模式。

LeNet-5模型是Yann LeCun教授于1998年在论文《Gradient-based learning applied to document recognition》中提出的,它是第一个成功应用于数字识别问题的卷积神经网络。在MNIST数据集上,LeNet-5模型可以达到大约99.2%的正确率。

图6-15

LeNet-5模型总共有7层,图6-15展示了LeNet-5模型的架构。在下面的篇幅中将详细介绍LeNet-5模型每一层的结构。

* 第一层,卷积层

这一层的输入就是原始的图像像素,LeNet-5模型接受的输入层大小为32x32x1。第一个卷积层过滤器的尺寸为5×5,深度为6,不使用全0填充,步长为1。因为没有使用全0填充,所以这一层的输出的尺寸为32-5+1=28,深度为6。
这一个卷积层总共有5x5x1x6+6=156个参数,其中 6个为偏置项参数。
因为下一层的节点矩阵有28x28x6=4704个节点,每个节点和5×5=25个当前层节点相连,所以本层卷积层总共有4704x(25+1)=122304个连接。

* 第二层,池化层

这一层的输入为第一层的输出,是一个28x28x6的节点矩阵。
本层采用的过滤器大小为2×2,长和宽的步长均为2,所以本层的输出矩阵大小为14×14×6。
原始的LeNet-5模型中使用的过滤器和6.3.2节介绍的有些细微差别,本书不做具体介绍。

* 第三层,卷积层

本层的输入矩阵大小为14×14×6,使用的过滤器大小为5x5,深度为16。
本层不使用全0填充,步长为1。本层的输出矩阵大小为10x10×16。
按照标准的卷积层,本层应该有5x5x6x16+16=2416个参数,10×10x16x(25+1)=41600个连接。

* 第四层,池化层

本层的输入矩阵大小为10x10x16,采用的过滤器大小为2×2,步长为2。本层的输出矩阵大小为5×5x16。

* 第五层,全连接层

本层的输入矩阵大小为5×5x16,在LeNet-5模型的论文中将这一层称为卷积层,但是因为过滤器的大小就是5×5,所以和全连接层没有区别,在之后的TensorFlow程序实现中也会将这一层看成全连接层。如果将5x5×16矩阵中的节点拉成一个向量,那么这一层和在第4章中介绍的全连接层输入就一样了。本层的输出节点个数为120,总共有5x5x16x120+120=48120 个参数。

* 第六层,全连接层

本层的输入节点个数为120个,输出节点个数为84个,总共参数为120x84+84=10164 个。

* 第七层,全连接层

本层的输入节点个数为84个,输出节点个数为10个,总共参数为84×10+10=850个。

上面介绍了LeNet-5模型每一层结构和设置,下面给出一个TensorFlow的程序来实现一个类似LeNet-5模型的卷积神经网络来解决MNIST数字识别问题。通过TensorFlow训练卷积神经网络的过程和第5章中介绍的训练全连接神经网络是完全一样的。损失函数的计算、反向传播过程的实现都可以复用5.5节中给出的mnist_train.py程序。
唯一的区别在于因为卷积神经网络的输入层为一个三维矩阵,所以需要调整一下输入数据的格式:

# 调整输入数据placeholder的格式,输入为一个四维矩阵。
x = tf.placeholder(
    tf.float32, 
    [
        BATCH_SIZE,                 # 第一维表示batch中样例个数
        mnist_inference.IMAGE_SIZE,   # 第二维 & 第三维 表示图片的尺寸
        mnist_inference.IMAGE_SIZE,   
        mnist_inference.NUM_CHANELS   # 第四维表示图片的深度,对于RGB格式的图片,深度为3
    ],
    name='x-input'
)

# 类似地,将输入的训练数据格式调整为一个四维矩阵,并将这个调整后的数据传入se ss.run()过程
reshape_xs = np.reshspe(
    xs, 
    (
        BATCH_SIZE, 
        mnist_inference.IMAGE_SIZE, 
        mnist_inference.IMAGE_SIZE, 
        mnist_inference.NUM_CHANNELS
    )
)

在调整完输入格式之后,只需要在程序mnist_inference.py 中实现类似LeNet-5模型结构的前向传播过程即可。
下面给出了修改后的mnist_inference.py程序:

# -*- coding: utf-8 -*- 
import tensorflow as tf 

# 配置神经网络的参数
INPUT_NODE = 784
OUTPUT_NODE = 10 

IMAGE_SIZE = 28 
NUM_CHANNELS = 1 
NUM_LABELS = 10 

# 第一层卷积层的尺寸和深度
CONV1_DEEP = 32 
CONV1_SIZE = 5 

# 第二层卷积层的尺寸和深度
CONV2_DEEP = 64 
CONV2_SIZE = 5 

# 全连接层的节点个数
FC_SIZE = 512 

# 定义卷积神经网络的前向传播过程,这里面添加了一个新的参数train,用于区分训练过程和测试过程。
# 在这个过程中将用到dropout方法,dropout可以进一步提升模型可靠性并防止过拟合,dropout只在训练时使用。
def inference(input_tensor, train, regularizer):
    # 声明第一层卷积层的变量并非前向传播过程。
    # 这个过程和6.3.1节中介绍的一致
    # 通过使用不同命名空间来隔离不同层的变量,这可以让每一层中的变量命名只需要考虑在当前层的作用,而不需要担心重名的问题。
    # 和标准LeNet-5模型大不一样,这里定义的卷积层输入为28x28x1的原始MNIST图片像素。
    # 因为卷积层中使用了全0填充,所以输出为28x28x32的矩阵
    with tf.variables_scope('layer1-conv1'):
        conv1_weights = tf.get_variable(
            "weight",
            [CONV1_SIZE, CONV1_SIZE, NUM_CHANNELS, CONV1_DEEP],
            initializer=tf.truncated_normal_initializer(stddev=0.1)
        )
        conv1_biases = tf.get_variable(
            "bias",
            [CONV1_DEEP]
            initializer=tf.constant_initializer(0.0)
        )

        # 使用变长为5,深度为32的过滤器,过滤器移动的步长为1,且使用全0填充。
        conv1 = tf.nn.conv2d(
            input_tensor,
            conv1_weights,
            strides=[1, 1, 1, 1], 
            padding='SAME'
        )
        relu1 = tf.nn.relu(
            tf.nn.bias_add(conv1, conv1_biases)
        )

    # 实现第二层池化层的前向传播过程
    # 这里选用最大池化层,池化层过滤器的边长为2,使用全0填充且移动的步长为2。
    # 这一层的输入是上一层的输出,也就是28x28x32的矩阵。
    # 输出为14x14x32的矩阵
    with tf.name_scope('layer2-pool'):
        pool1 = tf.nn.max_pool(
            relu1,
            ksize=[1, 2, 2, 1],
            strides=[1, 2, 2, 1],
            padding='SAME'
        )

    # 声明第三层卷积层的变量并实现前向传播过程。
    # 这一层的输入为14x14x32的矩阵
    # 输出为14x14x64的矩阵
    with tf.variable_scope('layer3-conv2'):
        conv2_weights = tf.get_variable(
            "weight",
            [CONV2_SIZE, CONV2_SIZE, CONV1_DEEP, CONV2_DEEP],
            initializer=tf.truncated_normal_innitializer(stddev=0.1)
        )
        conv2_biases = tf.get_varibale(
            "bias", 
            [CONV2_DEEP], 
            initializer=tf.constant_initializer(0.0)
        )

        # 使用边长为5,深度为64的过滤器,过滤器移动的步长为1,且使用全0填充
        conv2 = tf.nn.conv2d(
            pool1,
            conv2_weights, 
            strides=[1, 1, 1, 1],
            padding='SAME'
        )
        relu2 = tf.nn.relu(
            tf.nn.bias_add(conv2, co    nv2_biases)
        )

    # 实现第四层池化层的前向传播过程。
    # 这一层和第二层的结构是一样的。
    # 这一层的输入为14x14x64矩阵,输出为7x7x64矩阵
    with tf.name_scope('layer4-pool2'):
        pool2 = tf.nn.max_pool(
            relu2,
            ksize=[1, 2, 2, 1], 
            strides=[1, 2, 2, 1], 
            padding='SAME'
        )

    # 将第四层池化层的输出作为第五层全连接层的输入格式。
    # 第四层的输出为7x7x64的矩阵
    # 然而第五层全连接层需要的输入格式为向量,所以在这里需要将这个7x7x64的矩阵拉直成一个向量。
    # pool2.get_shape()函数可以得到第四层输出矩阵的维度而不需要手工计算。
    # 注意⚠️因为每一层神经网络的输入输出都为一个batch的矩阵,所以这里得到的维度也包含了一个batch中数据的个数。
    pool_shape = pool2.get_shape().as_list()
    # 计算将矩阵拉直成向量之后的长度,这个长度就是矩阵长宽以济深度的乘积。
    # 注意⚠️这里pool_shape[0]为一个batch中数据的个数
    nodes = pool_shape[1] * pool_shape[2] * pool_shape[3]

    # 通过 tf.reshape() 函数将第四层的输出变成一个batch的向量
    reshaped = tf.reshape(pool2, [pool_shape[0], nodes])

    # 声明第五层全连接层的变量并实现前向传播过程。
    # 这一层的输入是拉直之后的一组向量,向量长度为3136,输出是一组长为512的向量。
    # 这一层和之前在第5章中介绍的基本一致,唯一的区别就是引入了dropout的概念。
    # dropout在训练时会随机将部分节点的输出改为0。
    # dropout可以避免过拟合的问题,从而使得模型在测试数据上的效果更好。
    # dropout一般只在全连接层,而不是卷积层、池化层使用。

    with tf.variable_scope('layer5-fc1'):
        fc1_weights = tf.get_variable(
            "weigth", 
            [nodes, FC_SIZE],
            initializer=tf.truncated_normal_initializer(stddev=0.1)
        )
        # 只有全连接层的权重需要加入正则化
        if regularizer != None:
            tf.add_to_collection(
                'losses',
                regularizer(fc1_weights)
            )

        fc1_biases = tf.get_variable(tf.constant_initializer(0.1))

        fc1 = tf.nn.relu(tf.matmal(reshaped, fc1_weights) + fc1_biases)
        if train: fc1 = tf.nn.dropout(fc1, 0.5)

    # 声明第六层全连接层的变量并实现前向传播过程。
    # 这一层的输入为一组长度为512的向量,输出为一组长度为10的向量。
    # 这一层的输出通过Softmax之后,就得到了最后的分类结果。
    with tf.variable_scope('layer6-fc2'):
        fc2_weigths = tf.get_variable(
            "weight",
            [FC_SIZE, NUM_LABELS], 
            initializer=tf.truncated_normal_initializer(stddev=0.1)
        )
        if regularizer != None:
            tf.add_to_collection(
                 'losses',
                 regularizer(fc2_weights)
            )
        fc2_biases = tf.get_variable(
            "bias",
            [NUM_LABLES],
            initializer=tf.constant_initializer(0.1)
        )
        logit = tf.matmul(fc1, fc2_weights) + fc2_biases

    # 返回第六层的输出
    return logit

运行修改后的mnist_train.py,可以得到类似第5章中的输出:

~/mnist$ python mnist_train.py
Extracting /tmp/data/train-images-idx3-ubyte.gz 
Extracting /tmp/data/train-labels-idxl-ubyte.gz 
Extracting /tmp/data/t10k-images-idx3-ubyte .gz 
Extracting /tmp/data/t10k-labels-idxl-ubyte.gz
After 1 training step(s), loss on training batch is 6.45373.
After 1001 training step(s), loss on training batch is 0.824825.
After 2001 training step(s) , loss on training batch is 0.646993.
After 3001 training step(s), loss on training batch is 0.759975.
After 4001 training step(s), loss on training batch is 0.68468.
After 5001 training step(s) , loss on training batch is 0.630368.
...    

类似地修改第5章中给出的mnist_eval.py程序输入部分,就可以测试这个卷积神经网络在MNIST数据集上的正确率了。在MNIST测试数据上,上面给出的卷积神经网络可以达到大约99.4%的正确率。相比第5章中最高的98.4%的正确率,卷积神经网络可以巨幅提高神经网络在MNIST数据集上的正确率。

然而一种卷积神经网络架构不能解决所有问题。比如LeNet-5模型就无法很好地处理类似ImageNet这样比较大的图像数据集。那么如何设计卷积神经网络的架构呢?以下正则表达式公式总结了一些经典的用于图片分类问题的卷积神经网络架构:
输入层→(卷积层+→池化层?)+→全连接层+
在以上公式中:
* “卷积层+”表示一层或者多层卷积层,大部分卷积神经网络中一般最多连续使用三层卷积层。
* “池化层?”表示没有或者一层池化层。池化层虽然可以起到减少 参数防止过拟合问题,但是在部分论文中也发现可以直接通过调整卷积层步长来完成。所以有些卷积神经网络中没有地化层。
* 在多轮卷积层和池化层之后,卷积神经网络在输出之前一般会经过1~2个全连接层。
比如 LeNet-5模型就可以表示为以下结构:
输入层→卷积层→池化层→卷积层→池化层→全连接层→全连接层→输出层

除了 LeNet-5模型,2012年ImageNet ILSVRC图像分类挑战的第一名 AlexNet模型、2013年ILSVRC第一名ZF Net模型以及2014年第二名VGGNet模型的架构都满足上面介绍的正则表达式。

表6-1给出了VGGNet论文《Very Deep Convolutional Networks for Large-Scale Image Recognition》中作者尝试过的不同卷积神经网络架构。从表6-1中可以看出这些卷积神经网络架构都满足介绍的正则表达式。

有了卷积神经网络的架构,那么每一层卷积层或者池化层中的配置需要如何设置呢?表6-1也提供了很多线索。在表6-1中,convX-Y表示过滤器的边长为X,深度为Y。比如conv3-64表示过滤器的长和宽都为3,深度为64。
* 从表6-1中可以看出,VGG Net 中的过滤器边长一般为3或者1。在LeNet-5模型中,也使用了边长为5的过滤器。一般卷积层的过滤器边长不会超过5,但有些卷积神经网络结构中,处理输入的卷积层中使用了边长为7甚至是11的过滤器。
* 在过滤器的深度上,大部分卷积神经网络都采用逐层递增的方式。比如在表6-1中可以看到,每经过一次池化层之后,卷积层过滤器的深度会乘以2。虽然不同的模型会选择使用不同的具体数字,但是逐层递增是比较普遍的模式。
* 卷积层的步长一般为1,但是在有些模型中也会使用2,或者3作为步长。

池化层的配置相对简单,用得最多的是最大池化层。池化层的过滤器边长一般为2或者3,步长也一般为2或者3。

-6.4.2- Inception-v3模型

本节将介绍
* 设计卷积神经网络结构的另外一种思路--Inception模型。
* 简单介绍TensorFlow-Slim工具,并通过这个工具实现谷歌提出的Inception-v3模型中的一个模块

在6.4.1节中通过介绍LeNet-5模型整理出了一类经典的卷积神经网络架构设计。在这一节中将介绍Inception结构以及Inception-v3卷积神经网络模型

Inception 结构是一种和LeNet-5结构完全不同的卷积神经网络结构。
在LeNet-5模型中,不同卷积层通过串联的方式连接在一起,
而Inception-v3模型中的Inception结构是将不同的卷积层通过并联的方式结合在一起。

在下面的篇幅中将具体介绍Inception结构,并通过TensorFlow-Slim工具来实现Inception-v3模型中的一个模块。
在 6.4.1 中提到了一个卷积层可以使用边长为1、3或者5的过滤器,那么如何在这些边长中选呢?Inception模块给出了一个方案,那就是同时使用所有不同尺寸的过滤器,然后再将得到的矩阵拼接起来。


图 6-16

图6-16给出了Inception模块的一个单元结构示意图。从图6-16中可以看出,Inception模块会首先使用不同尺寸的过滤器处理输入矩阵。在图6-16中,最上方矩阵为使用了边长为1的过滤器的卷积层前向传播的结果。类似的,中间矩阵使用的过滤器边长为3,下方矩阵使用的过滤器边长为5。不同的矩阵代表了Inception模块中的一条计算路径。
虽然过滤器的大小不同,但如果所有的过滤器都使用全0填充且步长为1,那么前向传播得到的结果矩阵的长和宽都与输入矩阵一致。这样经过不同过滤器处理的结果矩阵可以拼接成一个更深的矩阵。如图6-16所示,可以将它们在深度这个维度上组合起来。
图6-16所示的Inception模块得到的结果矩阵的长和宽与输入一样,深度为红黄蓝三个矩阵深度的和。图6-16中展示的是Inception模块的核心思想,真正在Inception-v3模型中使用的Inception模块要更加复杂且多样,有兴趣的读者可以参考论文《Rethinking the Inception Architecture for Computer Vision》。

图6-17

图6-17给出了Inception-v3模型的架构图。Inception-v3模型总共有46层,由11个Inception模块组成。图6-17中方框标注出来的结构就是一个Inception模块。

在Inception-v3模型中有96个卷积层,如果将6.4.1节中的程序直接搬过来,那么一个卷积层就需要5行代码,于是总共需要480行代码来实现所有的卷积层。这样使得代码的可读性非常差。为了更好地实现类似Inception-v3模型这样的复杂卷积神经网络,在下面将先介绍TensorFlow-Slim工具来更加简洁地实现一个卷积层。以下代码对比了直接使用TensorFlow实现一个卷积层和使用TensorFlow-Slim实现同样结构的神经网络的代码量。

# 直接使用TensorFlow原始API实现卷积层。
with tf.variable_scope(scope_nname):
    weights = tf.get_variable("weight", ...)
    biases = tf.get_variable("bias", ...)
    conv = tf.nn.conv2d(...)
    relu = tf.nn.relu(tf.nn.bias_add(conv, biases))

# 使用TensorFlow-Slim实现卷积层。
# 通过TensorFlow-Slim可以在一行中实现一个卷积层的前向传播算法。
# slim.conv2d函数有3个参数是必填的:
# 第一个参数是输入的节点矩阵
# 第二个参数是当前卷积层过滤器的深度
# 第三个卷积层是过滤器的尺寸
# 可选的参数有过滤器移动的步长、是否全0填充、激活函数的选择、变量的命名空间等...
net = slim.conv2d(input, 32, [3, 3])

因为完整的Inception-v3模型比较长,所以在本书中仅提供Inception-v3模型中结构相对复杂的一个Inception模块的代码实现。
以下代码实现了图6-17中红色方框中的Inception模块:

# 加载slim库
slim = tf.contrib.slim

# slim.arg_scope函数可以用于设置默认的参数取值。
# slim.arg_scope函数的第一个参数是一个函数列表,在这个列表中的函数将使用默认的参数取值。
# 比如通过下面的定义,调用slim.conv2d(net, 320, [1, 1])函数时,会自动加上stride=1 和 padding='SAME' 的参数。
# 如果在函数调用时指定了stride,那么这里设置的默认值就不会再使用。
# 通过这种方式可以进一步减少冗余的代码。
with slim.arg_scope(
        [slim.conv2d,
        slim.max_pool2d,
        slim.avg_pool2d,
        stride=1,
        padding='VALID'
        ]
    ):
    ...
    # 此处省略了Inception-v3模型中其他网络结构
    # 而直接实现最后面红色框中的Inception结构。
    # 假设输入图片经过之前的神经网络结构前向传播的结果保存在变量net中。
    net = 上一层的输出节点矩阵
    # 为一个Inception模块生命一个统一的变量命名空间。
    with tf.variable_scope('Mixed_7c'):
        # 给Inception模块中每一条路径声明一个命名空间。
        with tf.variable_scope('Branch_0'):
            # 实现一个过滤器边长为1、深度为320的卷积层。
            branch_0 = slim.conv2d(net, 320, [1, 1], scope='Conv2d_0a_1x1')

        # Inception模块中第二条路径。
        # 这条计算路径上的结构本身也是一个Inception结构。
        with tf.variable_scope('Branch_1'):
            branch_1 = slim.conv2d(net, 384, [1, 1], scope='Conv2d_0a_1x1')
            # tf.concat() 函数可以将多个矩阵拼接起来。
            # tf.concat() 函数的第一个参数指定了拼接的维度,这里给出的“3”代表了矩阵在深度这个维度上进行拼接。
            # 图6-16中展示了在深度上拼接矩阵的方式。
            branch_1 = tf.concat(3, 
                [# 如图6-17所示,此处2层卷积层的输入都是branch_1而不是net。
                slim.conv2d(branch_1, 384, [1, 3], scope='Conv2d_0b_1x3'),
                slim.conv2d(branch_1, 384, [1, 3], scope='Conv2d_0c_3x1')
                ]
            )

        # Inception 模块中第三条路径。
        # 此计算路径也是一个Inception结构。
        with tf.variable_scope('Branch_2'):
            branch_2 = slim.conv2d(net, 448, [1, 1], scope='Conv2d_0a_1x1')
            branch_2 = slim.conv2d(branch_2, 384, [3, 3], scope='Conv2d_0b_3x3')
            branch_2 = tf.concat(3, 
                [slim.conv2d(branch_2, 384, [1, 3], scope='Conv2d_0c_1x3'),
                slim.conv2d(branch_2, 384, [3, 1], scope='Conv2d_0d_3x1')
                ]
            )

        # Inception 模块中第四条路径
        with tf.variable_scope('Branch_3'):
            branch_3 = slim.avg_pool2d(net, [3, 3], scope='AvgPool_0a_3x3')
            branch_3 = slim.conv2d(branch_3, 192, [1, 1], scope='Conv2d_0b_1x1')

        # 当前Inception模块的最后输出是由上面4个计算结果拼接得到的。
        net = tf.concat(3, [branch_0, branch_1, branch_2, branch_3])

-6.5- 卷积神经网络迁移学习

本节中,将:
* 介绍迁移学习的概念
* 介绍如何通过 TensorFlow 实现卷积神经网络的迁移学习

-6.5.1- 迁移学习介绍

本节中,将:
* 讲解迁移学习动机
* 介绍如何将一个数据集上训练好的卷积神经网络模型快速转移到另外一个数据集上

在6.4节中介绍了1998年提出的LeNet-5模型和2015年提出的Inception-v3模型。对比这两个模型可以发现,卷积神经网络模型的层数和复杂度都发生了巨大的变化。


表6-2

表6-2给出了从2012年到2015年ILSVRC(Large Scale Visual Recognition Challenge)第一名模型的层数以及前五个答案的错误率。从表6-2可以看到,随着模型层数及复杂度的增加,模型在ImageNet上的错误率也随之降低。

然而,
* 训练复杂的卷积神经网络需要非常多的标注数据。如6.1节中提到的,ImageNet图像分类数据集中有120万标注图片,所以才能将152层的ResNet的模型训练到大约96.5%的正确率。在真实的应用中,很难收集到如此多的标注数据。
* 即使可以收集到,也需要花费大量人力物力。
* 而且即使有海量的训练数据,要训练一个复杂的卷积神经网络也需要几天甚至几周的时间。

为了解决标注数据和训练时间的问题,可以使用本节将要介绍的迁移学习。所谓迁移学习,就是将一个问题上训练好的模型通过简单的调整使其适用于一个新的问题。

本节将介绍如何利用ImageNet数据集上训练好的Inception-v3模型来解决一个新的图像分类问题。根据论文《DeCAF: A Deep Convolutional Activation Feature for Generic Visual Recognition》中的结论,可以保留训练好的Inception-v3模型中所有卷积层的参数,只是替换最后一层全连接层。在最后这一层全连接层之前的网络层称之为瓶颈层(bottleneck)

将新的图像通过训练好的卷积神经网络直到瓶颈层的过程可以看成是对图像进行特征提取的过程。在训练好的Inception-v3模型中,因为将瓶颈层的输出再通过一个单层的全连接层神经网络可以很好地区分1000种类别的图像,所以有理由认为瓶颈层输出的节点向量可以被作为任何图像的一个更加精简且表达能力更强的特征向量。于是,在新数据集上,可以直接利用这个训练好的神经网络对图像进行特征提取,然后再将提取得到的特征向量作为输入来训练一个新的单层全连接神经网络处理新的分类问题。

一般来说,在数据量足够的情况下,迁移学习的效果不如完全重新训练。但是迁移学习所需要的训练时间和训练样本数要远远小于训练完整的模型。在没有GPU的普通台式机或者笔记本电脑上,6.5.2节中给出的TensorFlow训练过程只需要大约3小时,而且可以达到大概90%的正确率。

-6.5.2- TensorFlow实现迁移学习

本节中,将:
* 给出一个具体的TensorFlow程序将ImageNet上训练好的Inception-v3模型转移到另外一个图像分类数据集上

以下代码给出了如何下载这一节中将要用到的数据集:

wget http://download.tensorflow.org/example_images/flower_photos.tgz
tar xzf flower_photos.tgz

解压之后的文件夹包含了5个子文件夹,每一个子文件夹的名称为一种花的名称,代表了不同的类别。平均每一种花有734张图片,每一张图片都是RGB色彩模式的,大小也不相同。

和之前的样例不同,在这一节中给出的程序将直接处理没有整理过的图像数据。以下代码给出了如何将原始的图像数据整理成模型需要的输入数据。

#! /usr/bin/env python 
# -*- coding : utf-8 -*- 

import glob
import os.path
import numpy as np 
import tensorflow as tf 
from tensorflow.python.platform import gfile

# 原始输入数据的目录,这个目录下有5个子目录,每个子目录底下保存属于该类别的所有图片
INPUT_DATA = '/path/to/flower_photos'
# 输出文件地址。将整理后的图片数据通过numpy的格式保存。
# 在第7章中将更加详细地介绍数据预处理,这里先通过numpy来保存。
OUTPUT_FILE = '/path/to/flower_processed_data.npy'

# 测试数据和验证数据比例
VALIDATION_PERCENTAGE = 10
TEST_PERCENTAGE = 10 

# 读取数据并将数据分割成训练数据、验证数据、测试数据
def create_image_lists(sess, testing_percentage, validation_percentage):
    sub_dirs = [x[0] for x in os.walk(INPUT_DATA)]
    is_root_dir = True

    # 初始化各个数据集
    training_images = []
    training_labels = []
    testing_images = []
    testing_labels = []
    validation_images = []
    validation_labels = []
    current_label = 0 

    # 读取所有的子目录
    for sub_dir in sub_dirs:
        if is_root_dor:
            is_root_dir = False
            continue

        # 获取一个子目录中所有的图片文件
        extensions = ['jpg', 'jpeg', 'JPG', 'JPEG']
        file_list = []
        dir_name = os.path.basename(sub_dir)
        for extension in extensions:
            file_glob = os.path.join(INPUT_DATA, dir_name, '*.'+extension)
            file_list.extend(glob.glob(file_glob))
            if not file_list: continue

            # 处理图片数据
            for file_name in file_list:
                # 读取并解析图片,将图片转化为299x299以便inception-v3模型来处理。
                # 更多关于图像预处理的内容将在第7章中介绍。
                image_raw_data = gfile.FastGFile(file_name, 'rb').read()
                image = tf.image.decode_jpeg(image_raw_data)
                if image.dtype != tf.float32:
                    image = tf.image.convert_image_dtype(image, dtype=tf.float32)
                image = tf.image.resize_images(image, [299, 299])
                image_value = sess.run(image)

                # 随机划分数据集
                chance = np.random.randint(100)
                if chance < validation_percentage:
                    validation_images.append(image_value)
                    validation_labels.append(current_label)
                if chance < (testing_percentage + validation_percentage):
                    testing_images.append(image_value)
                    testing_labels.append(current_label)
                else:
                    training_images.append(image_value)
                    training_labels.append(current_label)
            current_label += 1 

    # 将训练数据随机打乱以获得更好的训练效果
    state = np.random.get_state()
    np.random.shuffle(training_images)
    np.random.set_state(state)
    np.random.shuffle(training_labels)
    
    return np.asarray([
        training_images,
        training_labels,
        validation_images,
        validation_labels,
        testing_images,
        testing_labels
    ])

# 数据整理主函数
def main():
    with tf.Session() as sess:
        processed_data = create_image_lists(
            sess,
            TEST_PERCENTAGE,
            VALIDATION_PERCENTAGE
        )
        # 通过numpy格式保存处理后的数据
        np.save(OUTPUT_FILE, processed_data)

if __name__ == "__main__":
    main()

运行以上代码可以讲所有的图片数据划分为训练、验证、测试3个数据集,并且将图片从原始的jpg格式转化为inception-v3模型需要的299x299x3的数字矩阵。

在数据处理完毕之后,通过以下命名可以下载谷歌提供的训练好的Inception-v3模型:

wget http://download.tensorflow.org/models/inception_v3_2016_08_28.tar.gz

# 解压之后可以得到训练好的模型文件 inception_v3.ckpt 
tar xzf inception_v3_2016_08_28.tar.gz

当新的数据集和已经训练好的模型都准备好之后,可以通过以下代码来完成迁移学习的过程:

#! /usr/bin/env python 
# -*- coding : utf-8 -*- 

import glob
import os.path
import numpy as np 
import tensorflow as tf 
from tensorflow.python.platform import gfile 
import tensorflow.contrib.slim as slim 

# 加载通过TensorFlow-Slim定义好的inception-v3模型。
import tensorflow.contrib.slim.python.slim.nets.inception_v3 as inception_v3

# 处理好之后的数据文件
INPUT_DATA = '/path/to/flower_processed_data.npy'
# 保存训练好的模型的路径。
# 这里可以将使用新数据训练得到的完整模型保存下来,
# 如果计算资源充足,还可以在训练完成后的全连接层之后再训练所有的网络层
# 这样可以使得新模型更加贴近新数据。
TRAIN_FILE = '/path/to/save_model'
# 谷歌提供的训练好的模型文件地址
CKPT_FILE = '/path/to/inception_v3.ckpt'

# 定义训练中使用的参数
LEARNING_RATE = 0.0001
STEPS = 300
BATCH = 32 
N_CLASSES = 5 

#  不需要从谷歌训练好的模型中加载参数。
# 这里就是最后的全连接层,因为在新的问题中要重新训练这一层中的参数。
# 这里给出的是参数的前缀。
CHECKPOINT_EXCLUDE_SCOPES = 'InceptionV3/Logits, InceptionV3/AuxLogits'
# 需要训练的网络层参数名称,在fine-tuning的过程中就是最后的全连接层。
# 这里给出的是参数的前缀。
TRAINABLE_SCOPES = 'InceptionV3/Logits, InceptionV3/AuxLogits'

# 获取所有需要从谷歌训练好的模型中加载的参数
def get_tuned_variables():
    exclusions = [scope.strip() for scope in CHECKPOINT_EXCLUDE_SCOPES.split(',')]
    variables_to_restore = []
    # 枚举inception-v3模型中所有的参数,
    # 然后判断是否需要从加载列表中移除
    for var in slim.get_model_variables():
        excluded = False
        for exclusion in exclusions:
            if var.op.name.startswitch(exclusion):
                excluded = True
                break
            if not excluded:
                variables_to_restore.append(var)
    return variables_to_restore

# 获取所有需要训练的变量列表
def get_trainable_variables():
    scopes = [scope.strip() for scope inn TRAINABLE_SCOPES.split(',')]
    variables_to_train = []
    # 枚举所有需要训练的参数前缀,并通过这些前缀找到所有的参数。
    for scope in  scopes:
        variables = tf.get_collection(
            tf.GraphKeys.TRAINABLE_VARIABLES, 
            scope
        )
        variables_to_train.extend(variables)
    return variables_to_train

def main():
    # 加载预处理好的数据
    processed_data = np.load(INPUT_DATA)
    training_images = processed_data[0]
    n_training_example = len(training_images)
    training_labels = processed_data[1]
    validation_images = processed_data[2]
    validation_labels = processed_data[3]
    testing_images = processed_data[4]
    testing_labels = processed_data[5]
    print("%d training examples, %d validation examples and %d testing examples."%(
        n_training_examples,
        len(validation_labels),
        len(testing_labels)
    ))
    # 定义inception-v3的输入,images为输入图片,labels为每一张图对应的标签
    images = tf.placeholder(
        tf.float32, 
        [None, 299, 299, 3],
        name='input_images'
    )
    labels = tf.placeholder(tf.int64, [None], name='labels')

    # 定义inception-v3模型.
    # 因为谷歌给出的只有模型参数取值,所以这里需要在这个代码中定义inception-v3的模型结构。
    # 虽然理论上需要区分寻来呢和测试中使用的模型,也就是说在测试时应该使用is_training=False,
    # 但是,因为预先训练好的inception-v3模型中使用的batch normalization参数与新的数据会有差异,导致结果很差
    # 所以这里直接使用同一个模型来进行测试。
    with slim.arg_scope(inception_v3.inception_v3_arg_scope()):
        logits, _ = inception_v3.inception_v3(
                images, 
                num_classes=N_CLASSES 
            )
    # 获取需要训练的变量
    trainable_variables = get_trainable_variables()
    # 定义交叉熵损失。
    # 注意在模型定义的时候已经将正则化加入损失集合了。
    tf.losses.softmax_cross_entropy(
        tf.one_hot(labels, N_CLASSES),
        logits, 
        weights=1.0 
    )
    # 定义训练过程。
    # 这里minimize的过程中制定了需要优化的变量集合
    train_step = tf.train.RMSPropOptimizer(LEARNING_RATE).minimize(
        tf.losses.get_total_loss()
    )

    # 计算正确率
    with tf.name_scope('evaluation'):
        correct_prediction = tf.equal(tf.argmax(logits, 1), labels)
        evaluation_step = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))

    # 定义加载模型的函数
    load_fn = slim.assign_from_checkpoint_fn(
        CKPT_FILE, 
        get_tuned_variables(), 
        ignore_missing_vars=True
    ) 

    # 定义好保存新的训练好的模型的函数
    saver =  tf.train.Saver()
    with tf.Session() as sess:
        # 初始化没有加载进来的变量。
        # 注意这个过程一定要在模型加载之前,
        # 否则初始化过程会讲已经加载好的变量重新赋值
        init = tf.global_variables_initializer()
        sess.run(init)

        # 加载谷歌已经训练好的模型
        print('Loading tuned variables from %s' % CKPT_FILE)
        load_fn(init)

        start = 0 
        end = BATCH 
        for i in range(STEPS):
            # 运行训练过程,这里不会更新全部的参数,只会更新置顶的部分参数。
            sess.run(train_step, feed_dict={
                images: training_images[start:end],
                labels: training_labels[start:end]
            })
            # 输出日志
            if i%30 == 0 or i+1 == STEPS:
                saver.save(sess, TRAIN_FILE, global_step=i)
                validation_accuracy = sess.run(evaluation, feed_dict={
                    images: validation_images, 
                    labels: validation_labels
                })
                print('Step %d: Validation accuracy = %.1f%%' % i, validation_accuracy*100.0)

            # 因为在数据预处理的时候已经做过了打乱数据的操作,
            # 所以这里只需要顺序使用训练数据就好。
            start = end 
            if start == n_training_examples:
                start = 0 
            end = start + BATCH
            if end > n_training_example:
                end = n_trainning_example

        # 在最后的试读数据上测试正确率
        test_accuracy = sess.run(evaluation_step, feed_dict={
            images: testing_images, 
            labels: testing_labels
        })
        print('Final test accuracy = %.1f%%' % (test_accuracy*100))

if __name__ == "__main__":
   tf.app.run()

运行以上程序大概需要3小时,可以得到类似下面的结果:

Step 0: Validation accuracy= 22.8% 
Step 30: Validation accuracy = 29.6%
Step 60: Validation accuracy= 63.2% 
Step 90: Validation accuracy = 81.2%
Step 120: Validation accuracy= 88.6% 
... 
Step 299: Validation accuracy = 91.5% 
Final test accuracy= 91.9% 

从以上结果可以看到,模型在新的数据集上很快能够收敛,并达到还不错的分类效果。

小结

在本章中详细介绍了如何通过卷积神经网络解决图像识别问题。
* 首先,在6.1节中讲解了什么是图像识别问题,并介绍了图像识别问题中的一些经典公开数据集。在这一节中介绍了不同算法在这些公开数据集上的表现,并指出卷积神经网络给这个领域带来了质的飞越。
* 接着,在6.2节中介绍了卷积神经网络的基本思想。在这一节中指出了全连接神经网络在处理图像数据上的不足之处,并给出了经典的卷积神经网络中包含的不同网络结构。
* 在6.3节中详细讲述了卷积神经网络中比较重要的两个网络结构一一卷积层和池化层。本节详细介绍了这两种网络结构的前向传播算法,并给出了TensorFlow中的代码实现。
* 在6.4节中,通过两种经典的卷积神经网络模型介绍了如何设计一个卷积神经网络的架构,井介绍了配置卷积层和池化层中设置的一些经验。在这一节中给出了一个完成的TensorFlow程序来实现LeNet-5模型,通过这个模型可以将MNIST数据集上的正确率进一步提升到大约99.4%。这一节中还简单介绍了TensorFlow-Slim工具,通过这个工具可以巨幅提高实现复杂神经网络的编程效率。
* 最后在6.5节中,介绍了迁移学习的概念并给出了一个完整的TensorFlow来实现迁移学习。通过迁移学习,可以使用少量训练数据在短时间内训练出效果还不错的神经网络模型。

在这一章中,通过图像识别问题介绍了卷积神经网络。通过这种特殊结构的神经网络,可以将图像识别问题的准确率提高到一个新的层次。
除了改进模型,TensorFlow还提供了很多图像处理的函数以方便图像处理。在第7章中将具体介绍这些函数。同时也将介绍如何使用TensorFlow更好地处理输入数据。

你可能感兴趣的:(TensorFlow 实战Google深度学习框架(第2版)第六章读书笔记)