深度学习和-CNN-计算机视觉应用实践指南-全-
深度学习和 CNN 计算机视觉应用实践指南(全)
原文:Practical Computer Vision Applications Using Deep Learning with CNNs
一、计算机视觉中的识别
大多数计算机科学研究试图建造一个像人类一样的机器人,能够完全像人类一样工作。甚至情感属性对于这样的机器人来说也不是不可能的。使用传感器,机器人可以感觉到周围环境的温度。利用面部表情,有可能知道一个人是悲伤还是快乐。即使是看似不可能的事情,最终也只会变得具有挑战性。
目前,一个非常具有挑战性的应用是对象识别。识别可以基于不同类型的数据,例如音频、图像和文本。但是图像识别是一种非常有效的方法,因为它有丰富的信息可以帮助我们完成任务。因此,它被认为是计算机视觉中最受欢迎的应用。
世界上存在大量的物体,区分它们是一项复杂的任务。除了细微的细节之外,不同的物体可能具有相似的视觉外观。此外,同一物体基于其周围环境而呈现出不同的外观。例如,根据光线、视角、扭曲和遮挡,同一对象在图像中会以不同的方式出现。根据原始图像,像素可能不是图像识别的好选择。这是因为每个像素的微小变化都会导致图像的重大变化,因此系统无法正确识别对象。目标是找到一组独特的属性或特征,只要对象的结构出现在图像中的某个地方,这些属性或特征即使随着像素位置或值的改变也不会改变。从图像中手动提取特征是图像识别中的一大挑战。这就是为什么自动特征提取方法正在成为一种选择。
因为在当前任何环境中识别任何对象都是复杂的,所以另一种方法是限制环境或目标对象。例如,我们可以不识别所有类型的动物,而只锁定其中的一组。与其在室内和室外工作,我们可以将环境限制在室内图像。我们可能只处理一些视图,而不是识别不同视图中的对象。一般来说,创建一个狭义的人工智能应用,虽然具有挑战性,但比一般的人工智能应用更容易,困难也更少。
本章讨论如何构建一个识别应用来对水果图像进行分类。它首先介绍一些对不同类型的应用普遍有用的特性,然后找出这些特性中最好的一个用于我们的目标应用。到本章结束时,我们将发现为什么手动提取特征是具有挑战性的,以及为什么使用卷积神经网络(CNN)的自动特征挖掘是首选的。
图像识别流水线
与大多数传统的识别应用类似,图像识别可能会遵循一些预定义的步骤,从接受输入到返回所需的结果。这些步骤的总结如图 1-1 所示。
图 1-1
通用识别流水线
有时输入图像的当前形式不适合处理。例如,如果我们要构建一个面部识别应用,该应用在复杂的环境中捕获图像以识别其中的人,那么在开始识别目标对象之前,最好先去除背景。在这种情况下,背景去除是一种预处理。通常,实际工作之前的任何步骤都称为预处理。预处理是使成功识别的概率最大化的步骤。
准备好输入后,我们开始实际工作,从特征提取或挖掘开始。这是大多数识别应用中的关键步骤。目标是找到一组能够准确描述每个输入的代表性特征。这样的一组特征应该最大化将每个输入映射到其正确输出的概率,并且最小化将每个输入分配给错误标签的概率。因此,应该对要使用的功能类型进行分析。
应用和所使用的功能集是相关的。基于应用选择特征。通过了解应用的性质,所需的功能类型将很容易被发现。对于人脸检测这样的应用,需要提取哪些特征?人脸具有各种颜色的皮肤,因此我们可以确定皮肤颜色是要使用的特征。知道该应用是在灰度户外图像、低照明和移动环境中检测人脸有助于选择适当的特征。如果要求您构建一个识别橙子和香蕉的应用,您可以从橙子和香蕉具有不同颜色的事实中受益,从而决定仅使用颜色特征就足够了。例如,它们不足以识别不同类型的皮肤癌。必须做更多的工作来找到最合适的特性集。下一节标题为特征提取讨论了一些在图像识别应用中有帮助的特征。
在创建了包含可能在识别应用中有用的特征的特征向量之后,我们进入添加进一步增强的其他步骤,即特征选择和减少。特征选择和减少的主要目标可以被定义为从一组特征中获得最佳特征子集,其通过减少不相关、相关和噪声特征的数量来增强学习算法的性能或准确性。标题为特征选择&减少的章节讨论了通过去除这些特征来减少特征向量长度的方法。
特征抽出
将图像以其原始形式作为输入应用于训练模型是不常见的。为什么提取特征是一种更好的方法,有不同的原因。一个原因是图像,即使是小图像,也有大量的像素,每个像素都作为模型的输入。对于大小为 100×100 像素的灰度图像,有 100×100 = 10,000 个输入变量要应用于模型。对于包含 100 个样本的小型数据集,整个数据集总共有 100×10,000 = 1,000,000 个输入。如果图像是红绿蓝(RGB),总数乘以 3。这除了计算量大之外,还需要大的存储器。
在训练之前优选特征提取的另一个原因是,输入图像具有不同类型的具有不同属性的对象,并且我们只想针对单个对象。例如,图 1-2(a) 显示了“狗与猫 Kaggle”比赛中一只狗的图像。我们的目标是发现狗,我们不关心树林或草地。如果将完整的图像用作模型的输入,木材和草地将会影响结果。最好只使用专属于狗的功能。从图 1-2(b) 可以明显看出,狗的颜色不同于图像中的其他颜色。
图 1-2
使用特征时,锁定图像中的特定对象更容易
通常,问题的成功建模依赖于最佳特征的选择。数据科学家应该为正在解决的问题选择最具代表性的功能集。有不同类型的特征用于描述图像。这些特征可以以不同的方式分类。一种方法是检查它们是从图像中的特定区域全局还是局部提取的。局部特征是诸如边缘和关键点的那些特征。全局特征是诸如颜色直方图和像素计数之类的特征。全局意味着该特征描述了整个图像。说颜色直方图在左侧区域居中意味着整个图像是暗的。该描述不仅仅针对图像的特定区域。局部特征集中在图像中的特定部分,例如边缘。
后续小节将讨论以下特性:
-
颜色直方图
-
边缘
- 猪
-
纹理
-
[军]GroundLaunchedCruiseMissile
-
梯度共生矩阵
-
垂直线间的距离
-
颜色直方图
颜色直方图表示颜色在图像中的分布。它通常用于灰色图像,但也有修改用于彩色图像。为简单起见,让我们计算图 1-3 中 5×5 2 位图像的颜色直方图。该图像只有 4 个灰度级。图像是使用 NumPy 随机生成的。
图 1-3
尺寸为 5×5 的两位灰度图像
通过计算每个灰度级的频率,直方图如图 1-4 所示。基于直方图,很明显高频箱位于右侧,因此图像是明亮的,因为其大多数像素是高的。
图 1-4
2 位 5×5 灰度图像的直方图
清单 1-1 给出了 Python 代码,除了计算和显示直方图之外,还用来随机生成前面的小图像。
import matplotlib.pyplot
import numpy
rand_img = numpy.random.uniform(low=0, high=3, size=(5,5))
rand_img = numpy.uint8(rand_img)
hist = numpy.histogram(rand_img, bins=4)
matplotlib.pyplot.bar(left=[0,1,2,3], height=hist[0], align="center", width=0.3)
matplotlib.pyplot.xticks([0,1,2,3], fontsize=20)
matplotlib.pyplot.yticks(numpy.arange(0, 15, 2), fontsize=20)
Listing 1-1Histogram for a Tiny Randomly Generated Image
numpy.random.uniform()
除了接受图像像素赋值范围的上限和下限之外,还接受要返回的数组大小。下限为 0,上限为 3,因为我们希望创建一个 2 位映像。numpy.uint8()
用于将浮点值转换为整数。然后,使用numpy.histogram()
计算直方图,它接受图像和箱数,并返回每个级别的频率。最后,matplotlib.pyplot.bar()
用于返回一个条形图,在 x 轴上显示每个级别,在 y 轴上显示其频率。matplotlib.pyplot.xticks()
和matplotlib.pyplot.yticks()
用于改变 x 轴和 y 轴的范围以及显示字体大小。
真实世界图像的直方图
让我们在将现实世界的图像转换成黑白图像后,计算如图 1-2(a) 所示的直方图。灰度图像和直方图如图 1-5 所示。似乎直方图大多集中在左侧部分,这意味着图像普遍较暗。因为狗的身体是白色的,所以部分直方图位于直方图分布的最右边。
图 1-5
灰度图像直方图
清单 1-2 给出了读取彩色图像、将其转换为灰度、计算其直方图并最终将直方图绘制成条形图的 Python 代码。
import matplotlib.pyplot
import numpy
import skimage.io
im = skimage.io.imread("69.jpg", as_grey=True)
im = numpy.uint8(im*255)
hist = numpy.histogram(im, bins=256)
matplotlib.pyplot.bar(left=numpy.arange(256), height= hist[0], align="center", width=0.1)
Listing 1-2Histogram for a Real-World Image
使用skimage.io.imread()
功能,使用as_grey
属性读取图像并将其转换为灰度。当设置为True
时,图像以灰度返回。返回的图像数据类型为float64
。为了将它转换成范围从 0 到 255 的无符号整数,使用了numpy.uint8()
。图像在转换前首先乘以 255,因为numpy.uint8()
不会改变输入的比例。它只是确保数字是由 8 位表示的整数。例如,将等于 0.7 的数字应用于该函数,结果为 0。我们希望将 0.4 从 0–1 范围重新调整到 0–255 范围,然后将其转换为 uint8。如果不将输入乘以 255,所有的值将只是 0 或 1。注意,直方图仓的数量被设置为 256,而不是上例中的 4,因为图像被表示为 8 位。
HSV 颜色空间
颜色直方图是指将图像像素表示在其中一个颜色空间中,然后统计这些颜色空间中存在的级别的频率。以前,图像是在 RGB 颜色空间中表示的,每个通道的颜色范围从 0 到 255。但是这不是唯一存在的颜色空间。
我们将涉及的另一个颜色空间是 HSV(色调-饱和度-值)。这个颜色空间的优点是颜色和照明信息的分离。色调通道保存颜色信息,其他通道(饱和度和值)指定颜色的亮度。以颜色而不是照明为目标并创建照明不变的特征是有用的。我们不会在本书中涉及 HSV 色彩空间,但是阅读更多关于如何使用 HSV 生成颜色的内容是很好的。值得一提的是,色调通道表示一个圆,其值在 0 到 360 之间,其中 0 度表示红色,120 度表示绿色,240 度表示蓝色,360 度返回红色。所以,它以红色开始和结束。
对于图 1-2(a) 中的图像,色调通道及其直方图如图 1-6 所示。当色调通道被表示为如图 1-6(a) 所示的灰度图像时,红色将被赋予高值(白色),如狗项圈所示。因为蓝色被赋予 240 的高色调值,所以它在灰度图像中更亮。色调值为 140 的绿色比 360 更接近 0;因此,它的颜色很深。请注意,狗的身体在 RGB 颜色空间中是白色的,在 HSV 中看起来是黑色的。原因是 HSV 不负责强度,而只负责颜色。在价值通道中会是白色的。
图 1-6
用 HSV 表示的彩色图像的色调通道及其直方图
根据清单 1-3 ,RGB 图像被转换到 HSV 颜色空间并显示其色调通道直方图。
import matplotlib.pyplot
import numpy
import skimage.io
import skimage.color
im = skimage.io.imread("69.jpg", as_grey=False)
im_HSV = skimage.color.rgb2hsv(im)
Hue = im_HSV[:, :, 0]
hist = numpy.histogram(Hue, bins=360)
matplotlib.pyplot.bar(left=numpy.arange(360), height=hist[0], align="center", width=0.1)
Listing 1-3Displaying the Image Histogram Using Matplotlib
因为色调通道是 HSV 颜色空间中的第一个通道,所以它被赋予索引 0 以获得返回。
对于不同的图像,特征应该是唯一的。如果不同的图像具有相同的特征,结果将是不准确的。颜色直方图有这样的缺点,因为它对于不同的图像可能是相同的。原因是颜色直方图只计算颜色的频率,不管它们在图像中的排列如何。图 1-7(a) 调换了图 1-3 中的图像。从图 1-7(b) 可以看出,尽管像素位置不同,但是图像在转置前后的直方图是相同的。
图 1-7
改变像素位置不会改变颜色直方图
人们可能认为这不是问题,因为一个好的特征描述符应该保持持久,即使图像发生了变化,如旋转和缩放。颜色直方图不能满足这个属性,因为即使图像完全不同,它也返回相同的直方图。为了解决这个问题,应该考虑像素强度和位置,以返回更有代表性的特征。这种特征的例子是纹理特征,例如 GLCM。
[军]GroundLaunchedCruiseMissile
一种流行的统计纹理分析方法依赖于从像素对之间的空间关系中提取的二阶统计量。这些特征中最流行的是从共现矩阵(CM)中提取的特征。其中一个 CMs 是灰度共生矩阵(GLCM)。根据其名称,它接受灰度图像作为输入,并返回 GLCM 矩阵作为输出。
GLCM 可以描述为二维直方图,它根据每对灰度级之间的距离来计算它们之间的共现次数。GLCM 与一阶直方图的不同之处在于,GLCM 不仅取决于强度,还取决于像素的空间关系。对于每两个像素,一个称为参考,另一个称为邻居。当两个强度级别之间的距离为 D 且角度为θ时,GLCM 会找出两个强度级别同时出现的次数。 GLCM (1,3),D = 1,θ = 0 是指强度值为 1 的参考像素与其强度为 3 的邻居相隔距离 D = 1,角度θ= 0?? 时共现的次数。当θ = 0 时,这意味着它们在同一水平线上。θ指定方向,D 指定该方向的距离。请注意,引用位于邻居的左侧。
计算 GLCM 的步骤如下:
-
如果输入图像是灰度或二进制的,直接使用它。如果是彩色图像,将其转换为灰度图像,或者在适当的情况下只使用其中一个通道。
-
找出图像中强度等级的总数。如果数字为 L,则从 0 到 L-1 对这些级别进行编号。
-
创建一个 LxL 矩阵,其中行和列的编号都是从 0 到 L 1。
-
选择合适的 GLCM 参数(D,θ)。
-
找出每两对强度等级之间的同现。
d 值
研究表明 D 的最佳值在 1 到 10 之间。较大的值将产生无法捕捉详细纹理信息的灰度共生矩阵。因此,对于 D=1,2,4,8,结果是准确的,D=1,2 是最好的。通常,一个像素可能与其附近的像素更相关。减小距离比增大距离会产生更好的结果。
θ值
对于 3×3 矩阵,中心像素有 8 个相邻像素。在这样的中心像素和所有其他 8 个像素之间,θ有 8 个可能的值,如图 1-8 所示。
图 1-8
中心像素与其八个相邻像素之间的θ值
因为选择θ设置为 0°和 180°得到的共现对是相等的(即 GLCM (1,3),θ = 0 = GLCM (3,1),θ= 180°,只需要一个角度就足够了。通常,相隔 180°的角度会返回相同的结果。这适用于角度(45,225),(135,315)和(90,270)。
当 D=1 且θ=0 时,让我们开始计算图 1-3 中的前一矩阵的 GLCM,在下面的矩阵中再次重复。因为该图像具有四个强度级别,所以当参考强度为 0 时,可用的对是(0,0)、(0,1)、(0,2)和(0,3)。当参考强度为 1 时,则配对为(1,0)、(1,1)、(1,2)和(1,3)。这种情况持续到 2 和 3。
| three | Two | Two | Zero | three | | one | three | Zero | Two | Two | | Two | Two | Two | Two | three | | three | three | three | Two | three | | Zero | Two | three | Two | Two |计算 GLCM (0,0),D = 1,θ = 0 ,数值将为 0。这是因为没有强度为 0 的像素与另一个强度为 0 的像素水平相距 1 个像素。对于对(0,1)、(1,0)、(1,1)、(1,2)、(2,1)和(3,1),结果也是 0。
对于 GLCM (0,2),D = 1,θ = 0 ,结果是 2,因为有三次强度 3 位于水平方向上距离强度 0 1 个像素的位置(即θ = 0 )。对于 GLCM (3,3),D = 1,θ = 0 ,结果也是 2。对于 GLCM (0,3),D = 1,θ = 0 ,结果为 1,因为强度 3 只出现一次,距离强度 0 为 1,角度为 0。这位于原始矩阵的右上方。
完整的 GLCM 如图 1-9 所示。该矩阵的大小为 4×4,因为它具有从 0 到 3 编号的 4 个强度等级。添加行和列标签是为了更容易知道哪个强度级别与另一个强度级别同时出现。
图 1-9
图 1-3 中距离为 1,角度为 0 的矩阵的 GLCM
清单 1-4 中给出了用于返回前面的 GLCM 的 Python 代码。
import numpy
import skimage.feature
arr = numpy.array([[3, 2, 2, 0, 3],
[1, 3, 0, 2, 2],
[2, 2, 2, 2, 3],
[3, 3, 3, 2, 3],
[0, 2, 3, 2, 2]])
co_mat = skimage.feature.greycomatrix(image=arr, distances=[1], angles=[0], levels=4)
Listing 1-4GLCM Matrix Calculation
skimage.feature.greycomatrix()
用于计算 GLCM。它接受输入图像、距离、计算矩阵的角度,最后是使用的级数。级别的数量很重要,因为默认值是 256。
请注意,每对唯一的角度和距离都有一个矩阵。只使用了一个角度和距离,因此返回了一个 GLCM 矩阵。返回输出的形状有四个数字,如下所示:
co_mat.shape = (4, 4, 1, 1)
前两个数字分别代表行数和列数。第三个数字代表使用的距离数。最后一个是角度数。如果要计算更多距离和角度的矩阵,则在skimage.feature.greycomatrix()
中指定。下一行使用两个距离和三个角度计算 GLCM。
co_mat = skimage.feature.greycomatrix(image=arr, distances=[1, 4], angles=[0, 45, 90], levels=4)
返回的矩阵的形状是
co_mat.shape = (4, 4, 2, 3)
因为有两个距离和三个角度,所以返回的 GLCMs 总数为 2×3 = 6。要在距离 1 和角度 0 处返回 GLCM,步进如下:
co_mat[:, :, 0, 0]
这将返回完整的 4×4 GLCM,但仅针对第一个距离(1)和第一个角度(0),根据它们在skimage.feature.greycomatrix()
函数中的顺序。为了返回对应于距离 4 和角度 90?? 的 GLCM,索引将如下:
co_mat[:, :, 1, 2]
GLCM 归一化
先前计算的 GLCMs 对于了解每个强度等级彼此共出现多少次是有用的。我们可以从这些信息中受益,以预测每两个强度级别之间的同现概率。GLCM 可以被转换成概率矩阵,因此我们可以知道当被距离 D 和角度θ分开时,两个强度等级 l 1 和l2 中的每一个之间的同现概率。这是通过将矩阵中的每个元素除以矩阵元素的总和来实现的。由此产生的矩阵被称为规范化或概率矩阵。根据图 1-9 ,所有元素的总和为 20。将每个元素除以后,归一化矩阵如图 1-10 所示。
图 1-10
距离为 1、角度为 0 的归一化 GLCM 矩阵
归一化 GLCM 的一个好处是输出矩阵中的所有元素都在从 0.0 到 1.0 的相同范围内。此外,结果与图像大小无关。例如,根据尺寸为 5×5 的图 1-9 ,对(2,2)的最高频率为 6。如果新图像更大(例如 100×100),则最高频率将不是 6,而是更大的值,例如 2000。我们不能比较 6 乘 2,000,因为这样的数字与图像大小有关。通过归一化矩阵,GLCM 的元素与图像大小无关,因此我们可以正确地比较它们。在图 1-10 中,对(2,2)给出的概率为 0.3,这与来自任何大小的任何图像的同现概率相当。
用 Python 规范化 GLCM 非常简单。基于名为normed
的布尔参数,如果设置为True
,结果将被归一化。默认设置为False
。归一化矩阵是根据下面这条线计算的:
co_mat_normed = skimage.feature.greycomatrix(image=arr, distances=[1], angles=[0], levels=4, normed=True)
GLCM 的大小为 4×4,因为我们使用的是只有 4 个级别的 2 位图像。对于图 1-2(a) 中的 8 位灰度图像,有 256 级,因此矩阵大小为 256×256。归一化的灰度共生矩阵如图 1-11 所示。两个地区的概率很大。第一个在左上角(低强度),因为背景是深色的。另一个区域在狗身体的右下方(高强度),因为它的颜色是白色的。
图 1-11
灰度图像的 GLCM 矩阵,256 级,距离为 6,角度为 0
随着级别数量的增加,矩阵的大小也会增加。图 1-11 中的 GLCM 有 256×256 = 65536 个元素。在特征向量中使用矩阵中的所有元素将大大增加其长度。我们可以通过从矩阵中提取一些特征来减少这个数字,包括相异度、相关性、同质性、能量、对比度和 ASM(角二阶矩)。清单 1-5 给出了提取这些特征所需的 Python 代码。
import skimage.io, skimage.feature
import numpy
img = skimage.io.imread('im.jpg', as_grey=True);
img = numpy.uint8(img*255)
glcm = skimage.feature.greycomatrix(img, distances=[6], angles=[0], levels=256, normed=True)
dissimilarity = skimage.feature.greycoprops(P=glcm, prop="dissimilarity")
correlation = skimage.feature.greycoprops(P=glcm, prop="correlation")
homogeneity = skimage.feature.greycoprops(P=glcm, prop="homogeneity")
energy = skimage.feature.greycoprops(P=glcm, prop="energy")
contrast = skimage.feature.greycoprops(P=glcm, prop="contrast")
ASM = skimage.feature.greycoprops(P=glcm, prop="ASM")
glcm_props = [dissimilarity, correlation, homogeneity, energy, contrast, ASM]
print('Dissimilarity',dissimilarity,'\nCorrelation',correlation,'\nHomogeneity',homogeneity,'\nEnergy',energy,'\nContrast',contrast,'\nASM',ASM)
Listing 1-5Extracting GLCM Features
GLCM 的一个缺点是依赖于灰度值。光照的微小变化都会影响最终的灰度共生矩阵。一种解决方案是使用梯度而不是强度来构建 CM。这种矩阵被称为基于灰度梯度的共生矩阵(GLGCM)。通过使用梯度,GLGCM 对于光照变化是不变的。
GLCM 和 GLGCM 都是图像变换的变体。也就是说,如果相同的灰度图像受到诸如旋转的变换的影响,描述符将产生不同的特征。一个好的特征描述符应该不受这些影响。
猪
灰度共生矩阵用于描述图像纹理,但不能描述图像强度的突变(即边缘)。有时纹理并不适合在问题中使用,我们必须寻找另一个特征。一类特征描述符用于描述图像边缘。这些特征描述了边缘的不同方面,例如边缘方向或方位、边缘位置以及边缘强度或大小。
本小节讨论一个称为梯度方向直方图(HOG)的描述符,它描述边缘方向。有时,目标对象具有唯一的移动方向,因此 HOG 是一个合适的特征。HOG 创建了一个表示边缘方向频率的直方图。让我们看看 HOG 是如何工作的。
图像渐变
图像中每对相邻像素之间的强度都有变化。为了测量这种变化,计算每个像素的梯度向量,以测量从该像素到其相邻像素的强度如何变化。该向量的大小是两个像素之间的亮度差。向量也反映了 X 方向和 Y 方向的变化方向。对于图 1-12 中的灰度图像,让我们计算第三行第四列的像素 21 在 X 和 Y 方向上的强度变化。
图 1-12
灰度图像计算其梯度
图 1-13 中显示了用于查找 X 和 Y 方向上的梯度幅度的掩模。让我们开始计算梯度。
图 1-13
用于计算水平和垂直梯度的遮罩
通过将水平蒙版在目标像素上居中,我们可以计算 X 方向的梯度。在这种情况下,相邻像素是 83 和 98。通过减去这些值,或者从右边的像素中减去左边的像素,或者从左边的像素中减去右边的像素,但在整个图像中保持一致:该像素的变化量为 9883 = 15。这种情况下使用的角度为 0°。
为了获得 Y 方向上该像素的变化量,垂直遮罩以目标像素为中心。然后,减去该像素的顶部和左侧像素,得出 6353 = 10。这种情况下使用的角度是 90 度。
在计算 X 和 Y 方向上的变化之后,接下来是根据等式 1-1 计算最终梯度幅度,以及根据等式 1-2 计算梯度方向。
(方程式 1-1)
(方程式 1-2)
梯度幅度等于
梯度方向
关于梯度方向,可以说该像素的变化方向在 0°处,因为 0°处的幅度高于 90°处的矢量。然而,其他人可能会说,像素不会在 0°或 90°处变化,而是在两个角度之间变化。这种角度是通过考虑 X 和 Y 方向来计算的。向量的方向是。结果,像素变化方向为 56.31。
计算完所有图像的角度后,下一步是为这些角度创建一个直方图。为了使直方图更小,并不使用所有的角度,而只是一组预定义的角度。最常用的角度是水平(0°)、垂直(90°)和对角(45°和 135°)。每个角度的贡献值等于根据等式 1-3 计算的梯度幅度。例如,如果当前像素对 Z 轴有贡献,它会将值 18.03 添加到其中。
有助于直方图仓
我们之前计算的角度是 56.31。这不是以前选择的角度之一。解决方案是将该角度分配给最近的直方图仓。56.31 位于仓 45 和 90 之间。因为 56.31 比 90 更接近 45,所以它将被分配到 bin 45。更好的方法是将该像素对这两个角度(45°和 90°)的贡献分开。
45 度角和 90 度角之间的距离是 45 度。56.31°角和 45°角之间的距离正好是∣56.31 45°∣= 11.31°。这意味着 56.31 度角与 45 度角相差一个等于的百分比。换句话说,56.31 是接近 45 的 75%。同样,56.31°角和 95°角之间的距离正好是∣56.31 90°∣= 33.69°。这意味着 56.31°的角度与 90°相差一个等于
的百分比。根据等式 1-3 计算角度添加到仓中的值。
(方程式 1-3)
其中像素 角度 为当前像素的方向,像素gradient magnitude为当前像素的渐变幅度, bin 角度 为直方图 bin 值, bin 间距 为每两个 bin 之间的间距量。
因此,56.31°的角度增加了 45°的 75%的梯度幅度,等于。它只将 25%的渐变幅度增加到 45 到 90,等于
。
更实际的直方图包含从 0°开始到 180°结束的九个角度。每对角度之间的差将是 180/9=20。因此,所用的角度为 0、20、40、60、80、100、120、140、160 和 180。箱不是这些角度,而是每个范围的中心。对于 0–20 范围,使用的 bin 是 10。对于 20–40,仓位为 30,依此类推。最终的直方图柱为 10、30、50、70、90、110、130、150 和 170。如果角度为 25 °,它会添加到其所在的面元中。也就是说,它添加到库 10(增加 0.25)和库 30(增加 0.75)。
通过对位于第二行第二列的像素 68 重复上述步骤,应用水平掩码的结果是 9750 = 47,这是 X 方向的梯度变化。应用垂直遮罩后,结果为 4323 = 20。根据等式 1-2,变化方向计算如下:
同样,合成角度不等于任何直方图区间。因此,该角度的贡献在它所落入的 15°和 45°的区间上被分开。它把 0.27 加到 45,又把 0.73 加到 45。
对于位于强度值为 88 的第四行第二列的像素,X 方向上的变化为 0。应用等式 1-2,结果将除以 0。为了避免被零除,在分母上加一个很小的值,如 0.0000001。
拱形台阶
至此,我们已经学会了如何计算任何像素的梯度大小和方向。但是在计算这些值之前和之后还有一些工作要做。HOG 步骤总结如下:
-
将输入图像分割成纵横比为 1:2 的面片。例如,补丁大小可能是 64×128、100×200 等等。
-
将补丁分成块(例如,四个块)。
-
将每个区块划分成单元格。块内的单元大小是不固定的。
- 例如,如果块大小为 16×16,我们决定将其分成四个单元,则每个单元的大小为 8×8。还要注意,块可能彼此重叠,并且一个单元可能在多个块中可用。
-
对于每个块中的每个单元,计算所有像素的梯度大小和方向。
-
基于图 1-13 中的掩模计算梯度。
-
梯度大小和方向分别根据等式 1-1 和 1-2 计算。
-
-
根据梯度的大小和方向,为每个像元构建直方图。如果用于构成直方图的角度数是 9,则每个单元返回一个 9×1 的特征向量。直方图是根据我们之前的讨论计算出来的。
-
连接同一块中所有单元的所有直方图,并只返回整个块的单个直方图。如果每个单元直方图由九个二进制表示,并且每个块具有四个单元,则级联直方图长度为 4×9=36。这个 36×1 的矢量是每个块的结果。
-
该矢量被归一化以使其对光照变化具有鲁棒性。
-
连接图像补片中所有块的归一化向量,以返回最终的特征向量。
图 1-14 显示了图 1-5(a) 中图像的一个补丁,尺寸为 64×128。
图 1-14
计算其 HOG 的图像补丁
在创建直方图之前,根据垂直和水平遮罩计算垂直和水平梯度。梯度如图 1-15 所示。
图 1-15
64×128 图像面片的垂直和水平渐变
清单 1-6 中给出了用于计算这种梯度的 Python 代码。
import skimage.io, skimage.color
import numpy
import matplotlib
def calculate_gradient(img, template):
ts = template.size #Number of elements in the template (3).
#New padded array to hold the resultant gradient image.
new_img = numpy.zeros((img.shape[0]+ts-1,
img.shape[1]+ts-1))
new_img[numpy.uint16((ts-1)/2.0):img.shape[0]+numpy.uint16((ts-1)/2.0),
numpy.uint16((ts-1)/2.0):img.shape[1]+numpy.uint16((ts-1)/2.0)] = img
result = numpy.zeros((new_img.shape))
for r in numpy.uint16(numpy.arange((ts-1)/2.0, img.shape[0]+(ts-1)/2.0)):
for c in numpy.uint16(numpy.arange((ts-1)/2.0,
img.shape[1]+(ts-1)/2.0)):
curr_region = new_img[r-numpy.uint16((ts-1)/2.0):r+numpy.uint16((ts-1)/2.0)+1,
c-numpy.uint16((ts-1)/2.0):c+numpy.uint16((ts-1)/2.0)+1]
curr_result = curr_region * template
score = numpy.sum(curr_result)
result[r, c] = score
#Result of the same size as the original image after removing the padding.
result_img = result[numpy.uint16((ts-1)/2):result.shape[0]-numpy.uint16((ts-1)/2),numpy.uint16((ts-1)/2):result.shape[1]-numpy.uint16((ts-1)/2)]
return result_img
Listing 1-7
Gradient Magnitude
Listing 1-6Calculating Gradients
基于接受灰度图像和遮罩的calculate_gradient(img, template)
函数,基于遮罩对图像进行过滤,然后将其返回。通过用不同的掩码(垂直和水平)调用它两次,返回垂直和水平渐变。
然后,根据清单 1-7 中的gradient_magnitude()
函数,使用垂直和水平梯度计算梯度幅度。
def gradient_magnitude(horizontal_gradient, vertical_gradient):
horizontal_gradient_square = numpy.power(horizontal_gradient, 2)
vertical_gradient_square = numpy.power(vertical_gradient, 2)
sum_squares = horizontal_gradient_square + vertical_gradient_square
grad_magnitude = numpy.sqrt(sum_squares)
return grad_magnitude
Listing 1-8
Gradient Direction
该函数只是将等式 1-1 应用于先前计算的垂直和水平梯度。补片图像的梯度大小如图 1-16 所示。
图 1-16
基于先前为 64×128 图像补片计算的垂直和水平渐变的渐变幅度
使用清单 1-8 中的函数gradient_direction()
,计算梯度方向。
def gradient_direction(horizontal_gradient, vertical_gradient):
grad_direction = numpy.arctan(vertical_gradient/(horizontal_gradient+0.00000001))
grad_direction = numpy.rad2deg(grad_direction)
# Some angles are outside the 0-180 range. Next line makes all results fall within the 0-180 range.
grad_direction = grad_direction % 180
return grad_direction
Listing 1-9Cell Histogram
注意添加到分母上的小值(0.00000001
)。这避免了被零除。忽略这一点,一些输出值将是 NaN(不是数字)。
图 1-17 显示了分割成 16×8 个单元后的图像块。每个单元具有 8×8 个像素,每个块具有 4 个单元(即,每个块具有 16×16 个像素)。
图 1-17
图像分为 16×8 个单元格
基于之前计算的梯度大小和方向,我们可以返回图像块中第一个 8×8 单元(左上角单元)的结果,如图 1-18 所示。
图 1-18
左上角 8×8 单元格的渐变幅度和方向
直方图将基于我们之前讨论的简单示例来创建。有 9 个直方图仓,覆盖从 0°到 180°的角度范围。仅使用有限数量的箱来表示这样的范围使得每个箱覆盖不止一个角度。仅使用 9 个箱,那么每个箱将覆盖 20 个角度。第一个箱覆盖从 0(包括 0)到 20(不包括 0)的角度。第二个从 20(含)到 40(不含),直到覆盖 160(含)到 180(含)角度的最后一个面元。每个范围的仓将被赋予一个等于每个范围中心的数字。也就是说,第一个箱被赋予 10,第二个箱被赋予 20,依此类推,直到最后一个箱被赋予 170。我们可以说,从第 20 步开始,仓从 10 到 170。对于图 1-18(a) 中的每个角度,找到其所在的两个直方图仓。从左上角的值为 44.41 的元素开始,它位于库 30 和 50 之间。根据等式 1-3,该值对这两个仓都有贡献。箱 30 的贡献值计算如下:
关于箱 50,贡献值计算如下:
对当前单元中的所有 8×8 像素继续该过程。左上角单元格的直方图如图 1-19(a) 所示。假设每个块包含 2×2 个单元,图 1-17 中用亮色标记的左上块中剩余三个单元的 9-bin 直方图也显示在图 1-19 中。通过计算给定块的所有直方图,其特征向量是这四个 9-bin 直方图的串联。特征向量的长度为 9×4 = 36。
图 1-19
当前图像块左上块内四个单元的九格直方图
计算完第一个块的特征向量后,选择下一个有四个单元格的块,在图 1-20 中用亮色标记。
图 1-20
图像补片中的第二个块以亮色突出显示
同样,如图 1-21 所示,计算该块内四个单元中每一个单元的九格直方图,并将它们的结果连接起来,返回 36×1 特征向量。
图 1-21
在当前图像块的图 1-20 中用亮色标记的第二个块内的四个单元的九格直方图
使用清单 1-9 中的HOG_cell_histogram()
函数计算每个单元格的直方图。该函数接受给定单元格的方向和大小,并返回其直方图。
def HOG_cell_histogram(cell_direction, cell_magnitude):
HOG_cell_hist = numpy.zeros(shape=(hist_bins.size))
cell_size = cell_direction.shape[0]
for row_idx in range(cell_size):
for col_idx in range(cell_size):
curr_direction = cell_direction[row_idx, col_idx]
curr_magnitude = cell_magnitude[row_idx, col_idx]
diff = numpy.abs(curr_direction - hist_bins)
if curr_direction < hist_bins[0]:
first_bin_idx = 0
second_bin_idx = hist_bins.size-1
elif curr_direction > hist_bins[-1]:
first_bin_idx = hist_bins.size-1
second_bin_idx = 0
else:
first_bin_idx = numpy.where(diff == numpy.min(diff))[0][0]
temp = hist_bins[[(first_bin_idx-1)%hist_bins.size, (first_bin_idx+1)%hist_bins.size]]
temp2 = numpy.abs(curr_direction - temp)
res = numpy.where(temp2 == numpy.min(temp2))[0][0]
if res == 0 and first_bin_idx != 0:
second_bin_idx = first_bin_idx-1
else:
second_bin_idx = first_bin_idx+1
first_bin_value = hist_bins[first_bin_idx]
second_bin_value = hist_bins[second_bin_idx]
HOG_cell_hist[first_bin_idx] = HOG_cell_hist[first_bin_idx] + (numpy.abs(curr_direction - first_bin_value)/(180.0/hist_bins.size)) * curr_magnitude
HOG_cell_hist[second_bin_idx] = HOG_cell_hist[second_bin_idx] + (numpy.abs(curr_direction - second_bin_value)/(180.0/hist_bins.size)) * curr_magnitude
return HOG_cell_hist
Listing 1-10Complete Implementation for Calculating Histogram for the Top-Left Cell
清单 1-10 给出了用于读取图像补丁的完整代码,并返回第一个块中左上角单元格的直方图。请注意,该代码适用于灰度图像。如果输入图像是灰度图像,它将只有两个维度。如果输入图像是彩色的,那么它将具有表示通道的第三维。在这种情况下,只使用一个灰度通道。使用 ndim 属性返回 NumPy 数组的维数。
import skimage.io, skimage.color
import numpy
import matplotlib.pyplot
def calculate_gradient(img, template):
ts = template.size #Number of elements in the template (3).
#New padded array to hold the resultant gradient image.
new_img = numpy.zeros((img.shape[0]+ts-1,
img.shape[1]+ts-1))
new_img[numpy.uint16((ts-1)/2.0):img.shape[0]+numpy.uint16((ts-1)/2.0),
numpy.uint16((ts-1)/2.0):img.shape[1]+numpy.uint16((ts-1)/2.0)] = img
result = numpy.zeros((new_img.shape))
for r in numpy.uint16(numpy.arange((ts-1)/2.0, img.shape[0]+(ts-1)/2.0)):
for c in numpy.uint16(numpy.arange((ts-1)/2.0,
img.shape[1]+(ts-1)/2.0)):
curr_region = new_img[r-numpy.uint16((ts-1)/2.0):r+numpy.uint16((ts-1)/2.0)+1,
c-numpy.uint16((ts-1)/2.0):c+numpy.uint16((ts-1)/2.0)+1]
curr_result = curr_region * template
score = numpy.sum(curr_result)
result[r, c] = score
#Result of the same size as the original image after removing the padding.
result_img = result[numpy.uint16((ts-1)/2.0):result.shape[0]-numpy.uint16((ts-1)/2.0), numpy.uint16((ts-1)/2.0):result.shape[1]-numpy.uint16((ts-1)/2.0)]
return result_img
def gradient_magnitude(horizontal_gradient, vertical_gradient):
horizontal_gradient_square = numpy.power(horizontal_gradient, 2)
vertical_gradient_square = numpy.power(vertical_gradient, 2)
sum_squares = horizontal_gradient_square + vertical_gradient_square
grad_magnitude = numpy.sqrt(sum_squares)
return grad_magnitude
def gradient_direction(horizontal_gradient, vertical_gradient):
grad_direction = numpy.arctan(vertical_gradient/(horizontal_gradient+0.00000001))
grad_direction = numpy.rad2deg(grad_direction)
grad_direction = grad_direction%180
return grad_direction
def HOG_cell_histogram(cell_direction, cell_magnitude):
HOG_cell_hist = numpy.zeros(shape=(hist_bins.size))
cell_size = cell_direction.shape[0]
for row_idx in range(cell_size):
for col_idx in range(cell_size):
curr_direction = cell_direction[row_idx, col_idx]
curr_magnitude = cell_magnitude[row_idx, col_idx]
diff = numpy.abs(curr_direction - hist_bins)
if curr_direction < hist_bins[0]:
first_bin_idx = 0
second_bin_idx = hist_bins.size-1
elif curr_direction > hist_bins[-1]:
first_bin_idx = hist_bins.size-1
second_bin_idx = 0
else:
first_bin_idx = numpy.where(diff == numpy.min(diff))[0][0]
temp = hist_bins[[(first_bin_idx-1)%hist_bins.size, (first_bin_idx+1)%hist_bins.size]]
temp2 = numpy.abs(curr_direction - temp)
res = numpy.where(temp2 == numpy.min(temp2))[0][0]
if res == 0 and first_bin_idx != 0:
second_bin_idx = first_bin_idx-1
else:
second_bin_idx = first_bin_idx+1
first_bin_value = hist_bins[first_bin_idx]
second_bin_value = hist_bins[second_bin_idx]
HOG_cell_hist[first_bin_idx] = HOG_cell_hist[first_bin_idx] + (numpy.abs(curr_direction - first_bin_value)/(180.0/hist_bins.size)) * curr_magnitude
HOG_cell_hist[second_bin_idx] = HOG_cell_hist[second_bin_idx] + (numpy.abs(curr_direction - second_bin_value)/(180.0/hist_bins.size)) * curr_magnitude
return HOG_cell_hist
img = skimage.io.imread("im_patch.jpg")
if img.ndim >2:
img = img[:, :, 0]
horizontal_mask = numpy.array([-1, 0, 1])
vertical_mask = numpy.array([[-1],
[0],
[1]])
horizontal_gradient = calculate_gradient(img, horizontal_mask)
vertical_gradient = calculate_gradient(img, vertical_mask)
grad_magnitude = gradient_magnitude(horizontal_gradient, vertical_gradient)
grad_direction = gradient_direction(horizontal_gradient, vertical_gradient)
grad_direction = grad_direction % 180
hist_bins = numpy.array([10,30,50,70,90,110,130,150,170])
cell_direction = grad_direction[:8, :8]
cell_magnitude = grad_magnitude[:8, :8]
HOG_cell_hist = HOG_cell_histogram(cell_direction, cell_magnitude)
matplotlib.pyplot.bar(left=numpy.arange(9), height=HOG_cell_hist, align="center", width=0.8)
matplotlib.pyplot.show()
在计算了块的特征向量之后,下一步是归一化该向量。特征归一化的动机是特征向量依赖于图像强度水平,并且最好使其对光照变化具有鲁棒性。归一化通过将向量中的每个元素除以根据等式 1-4 计算的向量长度来进行。
(方程式 1-4)
其中XI代表矢量元素编号 i 。归一化向量是第一块的结果。该过程继续,直到返回所有块的所有 36×1 特征向量。然后,将这些向量连接起来,用于正在处理的整个图像补片。
根据前面的讨论,HOG 在计算之前需要指定以下参数:
-
方向的数量。
-
每个单元格的像素数。
-
每个块的单元数。
HOG 已经在 Python 的skimage.feature
模块中实现,可以根据skimage.feature.hog()
函数轻松使用。前面三个参数有默认值,可以根据您的目标进行更改。如果normalized
参数设置为True
,则返回标准化的 HOG。
skimage.feature.hog(image, orientations=9, pixels_per_cell=(8, 8), cells_per_block=(3, 3), visualise=False, transform_sqrt=False, feature_vector=True, normalise=None)
垂直线间的距离
LBP 代表局部二元模式,这是另一种二阶纹理描述符。提取 LBP 特征的步骤如下:
-
将图像分成块(例如,16×16 块)。
-
对于每个块,一个 3×3 的窗口位于每个像素的中心。
根据等式 1-5,将所选中心像素 P 中心 与其周围 8 个邻居 P 邻居 中的每一个进行比较。从八次比较中,将有八个二进制数字。
(方程式 1-5)
-
8 位二进制代码被转换成整数。整数范围从 0 到 2 8 = 255。
-
用计算出的整数替换 P 中央 的值。
-
在计算同一块内所有像素的新值后,计算直方图。
-
在计算了所有块的直方图后,它们被连接起来。
假设我们当前正在处理的块在图 1-12 中,我们可以基于它开始计算基本的 LBP。
通过处理第三行和第四列的像素,将中心像素与八个相邻像素中的每一个进行比较。图 1-22 显示了比较的结果。
图 1-22
将中心像素与其八个相邻像素进行比较的结果
接下来是返回二进制代码。您可以从 3×3 矩阵中的任何位置开始,但必须在整个图像中保持一致。例如,从左上位置开始顺时针移动,代码为 11011101。你可以顺时针或逆时针移动,但要保持一致。
此后,通过将每个二进制数乘以对应于其在二进制码中的位置的权重相加,将二进制码转换成十进制码。结果是 128 + 64 + 16 + 8 + 4 + 1 = 221。
在计算块中每个像素的二进制代码并返回其十进制代码后,就创建了直方图。对所有图像块重复该过程,并且像在 HOG 的情况下一样,连接来自所有块的直方图。
这是 LBP 的基本实现,但是这种特征描述符具有多种变化,使得它对光照变化、缩放和旋转具有鲁棒性。
使用接受三个参数的skimage.feature.local_binary_pattern()
函数可以很容易地在 Python 中实现它:
-
输入图像。
-
圆中相邻点的数目(P)。该参数有助于实现旋转不变性。
-
圆的半径(R)。这种参数有助于实现比例不变性。
这里是一个 LBP 应用于图 1-2(a) 中的灰度图像的例子。
import skimage.feature
import skimage.io
import matplotlib.pyplot
im = skimage.io.imread("69.jpg", as_grey=True)
lbp = skimage.feature.local_binary_pattern(image=im, P=9, R=3)
matplotlib.pyplot.imshow(lbp, cmap="gray")
matplotlib.pyplot.xticks([])
matplotlib.pyplot.yticks([])
输出图像如图 1-23 所示。
图 1-23
在 P-9 和 R=3 的灰度图像上应用 LBP 的输出
特征选择和减少
假设在与某个领域的专家讨论之后,您推断出特性 X、Y 和 Z 是合适的。这些功能只是最初选择的,其中一些功能可能没有帮助。您可以根据一些实验来决定每个特性是好是坏。在基于这三个特征训练模型之后,识别率较低,并且必须在特征向量中改变某些东西。为了知道原因,您通过为每个单独的特征训练模型来进行一些实验。您发现特征 Z 和目标之间的相关性很低,因此决定不使用该特征。从特征向量中完全消除某些特征并保留其他特征称为特征选择。选择技术将特征分为好的或坏的。坏的特征被完全消除,而好的特征被专门使用。
事实上,每个特性中的一些元素可能不适合所分析的应用类型。假设在特征向量中使用特征 X,并且该特征具有一组 10 个元素。这些元素中的一个或多个可能对任务没有帮助。例如,有些功能是多余的。也就是说,一些特征可能彼此最佳相关,因此仅一个特征就足以描述数据,并且不需要使用工作相同的多个特征。由于相关的特征,可能不存在唯一的最优特征子集。这是因为可能有不止一个完全相关的特征,因此一个可以替换另一个并创建新的特征子集。
另一类是无关的特性。一些特征与所需的预测无关,它们被视为噪声。这样的特征不会增强而是降低结果。因此,最好检测这些特征并删除它们,这样它们就不会影响学习过程。只删除元素的一个子集而保留其他的叫做特征约简。
通过尽可能去除坏特征来减少特征向量长度的另一个动机是,特征向量长度越长,在训练和测试模型时消耗的计算时间就越多。
要将要素分类为相关或不相关,需要一些度量来显示每个要素与输出类的相关性或每个要素预测所需输出的程度。特征相关性是特征区分不同类别的能力。在选择度量之后,它们将被用于通过消除坏的特征来创建好的特征子集。特征消除方法分为监督和非监督方法。有监督的方法包括过滤器和包装器,无监督的方法包括嵌入式。
过滤器
过滤方法增加了额外的预处理步骤,以应用可变排序技术,基于为每个特征计算的不同标准来对特征进行排序,从而测量特征的相关性。这些标准包括标准偏差(STD)、能量、熵、相关性和互信息(MI)。基于阈值,选择排名高的特征来训练模型。与其他选择方法相比,过滤方法非常快速且不耗时。它们的计算也很简单,可扩展,能够避免过拟合,并且独立于学习模型。
对于训练不同的模型,选择只进行一次,然后训练模型可以使用所选择的特征。但是,与其他特征选择方法相比,过滤方法有许多严重影响其性能的严重缺点。过滤/分级方法没有对特征依赖性建模。它独立于同一子集中的其他特征/变量来选择每个变量/特征。当根据所选择的标准被高度排序时,特征被选择。用于排序的标准没有考虑多个特征之间的关系。忽略特征依赖性会破坏整个所选子集,因为当与其他特征组合时,不能保证那些本身对提高学习速率很重要的特征也是如此。在某些情况下,有用的变量在组合在一起时仍然有用,但情况并非总是如此。
如果两个特征 f 1 和 f 2 的有用性分别是 x 1 和 x 2 ,这并不意味着它们组合在一起时有用性就会是x1+x2。此外,忽略特性依赖会导致冗余和相关的特性。这是因为可能有两个或两个以上的特征完全满足标准,但它们中的每一个都是做相同任务的所有其他特征的完美反映。所以不要求使用同一事物的倍数;一个就够了。相关性是冗余的另一种形式,在这种形式中,特征可能不相同,但是相互依赖,并且总是工作相同(可以表示为两条平行线)。完全相关的变量确实是多余的,因为它们没有增加额外的信息。冗余和相关特征的使用导致大的特征向量,因此不能实现特征选择减少特征向量长度的好处。过滤方法不与学习模型交互,因为它们不依赖于学习算法性能,这是由于将特征选择与性能解耦。相反,这些只是考虑特征和类标签之间的单个标准,而不是指示特征与学习算法工作得如何的信息。一个好的特征选择器应该考虑学习算法和训练数据集如何交互。最后,计算用于将特征分类为选中或未选中的阈值并不是一件容易的事情。所有这些原因导致了克服这些问题的其他特征选择方法的使用。
包装材料
包装器方法是解决过滤方法的一些问题的第二种方法。包装器方法试图通过创建使性能最大化的所选特征的子集来与学习模型交互。包装器方法创建所有可能的特征子集,以找到最佳子集。包装器方法之所以这么叫,是因为它围绕着学习算法。它使用归纳或学习算法作为黑盒,通过用所选子集训练算法来测量所选特征子集的工作情况,然后使用最大化其性能的算法。当谈到包装器方法时,应该很好地涵盖多个要点,包括选择特征子集长度、创建特征子集空间、搜索特征子集空间、评估学习算法的性能、停止搜索标准以及确定使用哪个学习算法。任何特征缩减/选择算法的目标都是从长度为 N 的原始完整特征向量中创建长度为 L 个特征的所有可能的特征组合,以最大化性能,其中< N 。对于长度=30 的正常特征向量,有大量的组合来创建长度为 L = 10 的子集。为此,应用搜索策略来搜索最佳子集,并使用评价函数惩罚不良子集。在这种情况下,目标函数是模型性能。这样,问题就从学习问题转化为搜索问题。穷举搜索不适用于这种问题,因为它访问和训练具有所有子集的学习模型,这在计算方面是密集的。因此,进化算法(EAs)被用来避免探索所有的子集,这些算法可以分为两类:顺序选择算法(确定性的)和启发式搜索算法(随机的)。
确定性搜索算法被进一步分为实际上非常相似的两类,即前向选择和后向选择。在前向选择中,该算法从表示空集特征的根开始,然后逐个特征地添加,同时为每个变化训练学习模型。在后向选择中,搜索的根是完整的特征集,然后算法逐个删除特征,同时为每个变化训练学习模型。这种算法的例子包括顺序特征选择(SFS)、顺序向后选择(SBS)、顺序向前浮动选择(SFFS)、自适应 SFFS (ASFFS)和波束搜索。为了防止搜索穷尽,添加了停止标准以防止探索所有可能的组合。该标准可以是前向选择的最大特征向量长度或后向选择的最小长度。它也可以是一个最大性能,因此在达到选定的性能后,搜索停止。
第二类搜索算法,随机算法,是使用启发式评估函数的通知搜索,生成一个启发式值,告诉每个子集有多接近最大性能。这种搜索算法的例子包括遗传算法(GA)、粒子群优化(PSO)、模拟退火(SA)和随机爬山。GA 将在第五章中详细讨论。
一个必须回答的问题是 L (特征数)的最优值是多少?包装器方法有不同的方法来回答这个问题。一种方法是选择固定数量的特征来创建特征向量,并且通过使用组合,有可能获得从 N 个特征中仅选择 L 个特征的所有可能性。但不幸的是,为 L 选择的固定值可能不是最佳值,并且不能保证 L 选择的特征是为学习模型提供最佳性能的特征。因此,另一种方法是使被选择的特征的数量可变并且动态地变化。这是通过尝试不同数量的特征并选择最佳数量的特征来最大化性能,如同在顺序选择算法中一样。使特征的数量动态变化的缺点是通过创建不同长度的不同特征组合并用它们训练模型来增加越来越多的计算时间。为了减少这个时间,可以添加一个标准,使得在达到目标性能之后或者在所选特征的数量达到最大长度之后,学习更早地停止。
将包装器方法与过滤器方法相比较,有许多优点。包装器方法与学习模型交互,因为它使用学习算法性能作为选择最佳特征子集的度量。它还对功能依赖关系进行建模,因为功能不是单独或相互独立选择的,并监控将功能组合在一起对性能的影响。最后,它对冗余和相关特征具有鲁棒性。但是各种包装方法都有许多缺点。它们非常耗时,因为每个模型都需要多次训练。有些模型非常耗时,一次训练可能需要几个小时。因此,包装方法不是这种模型的选项。此外,包装器方法受到过度拟合的影响,因为选择依赖于学习模型,因此不能在不同的模型上概括选择的特征。
植入的
过滤器和包装器这两种特性选择方法各有优缺点。嵌入式方法试图结合过滤器和包装器方法的优点。这种方法并不耗时,因为它们避免了在过滤器和包装器方法中看到的学习算法的再训练。它们通过与学习模型交互来最大化性能,就像在包装器方法中一样。仅当该特征与输出类标签 m 相关时,才选择该特征。之所以称之为嵌入式,是因为它通过在训练步骤中嵌入特征选择来工作。
嵌入式方法的类别如下:修剪、内置和正则化(惩罚)。修剪方法首先用完整的特征集训练学习模型,然后计算每个特征的相关系数。这些系数用于根据所使用的模型对特征的重要性进行排序。系数的高值反映了强相关性。嵌入式特征选择的内置方法计算每个特征的信息增益,就像决策树学习(ID3)一样。
在机器学习(ML)中,一些模型被训练得非常好,可以对训练数据中的任何样本做出正确的预测,但不幸的是,不能对训练样本之外的其他样本做出正确的预测。这个问题叫做过拟合。正则化是一种用来避免这个问题的技术。正则化是调整或选择最佳的模型复杂度以适应训练数据,同时能够预测看不见的样本。如果不进行正则化,模型可能会非常简单且拟合不足(无法对训练样本和测试样本都做出正确的预测),或者非常复杂且过度拟合(对训练样本的预测正确,但对测试样本的预测错误)。欠拟合和过拟合都使得模型太弱,不能推广到任何样本。因此,正则化是一种概括模型以预测任何样本的方法,无论是训练还是测试。
正规化
为了找到最佳模型,ML 中的常用方法是定义一个描述模型与数据拟合程度的损失或成本函数。目标是找到最小化这个损失函数的模型。通常,任何学习模型中的目标函数只有一个标准,即最大化性能。正则化方法为目标函数增加了另一个标准,以控制复杂程度,如等式 1-6 所示。
L = min 误差 ( Y 预测 , Y 校正 ) + λ罚(WI)(方程式 1-6)
其中 Y 预测 为预测类标签, Y 正确 为正确类标签,错误(。)计算预测误差, W i 是特征元素 X i 的权重, λ 是控制模型复杂度的正则化参数。该参数用于控制目标函数和惩罚之间的权衡。根据等式 1-7 定义罚值。
(方程式 1-7)
通过改变 λ 值,模型复杂度发生变化。这是通过将一些特征的权重设置为接近或等于零来惩罚它们。系数的大小是决定模型复杂性的一个重要因素。通过选择具有高权重幅度的特征来间接实现特征选择。权重越高,预测正确类别的特征越相关。这就是为什么正规化的方法被称为惩罚。
正则化参数 λ 的目标是最小化损失 L 并使其保持最小。对于接近∞λ的非常大的值,系数必须小并且接近零,以使总值尽可能小。这使得大部分系数为零,从而消除它们。对于一个值为 0 < ʎ < ∞,会有一些等于零的系数被去掉,但等于零的并不多。 λ 的最佳值是多少? λ 没有固定值,可以使用交叉验证(CV)有效地计算其值。
结合过滤器和包装器方法的优点使得嵌入式方法成为特征选择的最新研究趋势。它与学习模型交互,因为它像包装方法一样使用训练模型性能作为度量,不像过滤方法那样耗时,因为它不需要重新训练模型,并且还对特征依赖性进行建模以避免冗余和相关的特征。在训练时选择特征在数据使用方面是有效的,因为不需要将数据分成训练集和验证集。然而,尽管通过嵌入式方法选择的特征对于用于选择特征的学习模型来说表现良好,但是所选择的特征可能依赖于这样的模型,并且不会像使用过滤器方法产生的那样在不同的模型之间工作良好。
二、人工神经网络
机器学习(ML)问题可以分为三类:有监督的、无监督的和强化的。在监督学习中,人类专家在受限环境中进行一些实验,并注意到它们的结果。监督学习算法探索从实验中收集的数据,以将输入映射到输出。例如,一个受限的环境可能有一个机器人想要从一个小房间的一边走到另一边。房间里有一些障碍物可能会使机器人摔倒。主管提供如何到达墙壁而不摔倒的指导。这是通过以例子的形式给机器人知识来帮助它学习如何通过障碍来实现的。机器人利用这些知识来增加通过障碍而不摔倒的概率。在这种情况下,机器人的知识完全依赖于人类。
在强化学习中,人类给机器人一个衡量标准来评估它的表现。机器人必须最大化这个度量来达到它的目标。它不知道何时向右移动。基于度量,机器人将尝试移动不同的位置并计算度量。如果机器人掉在一个给定的位置,那么下次它必须避开它。这样,机器人就会找到使它到达目标而不摔倒的路。
与监督和半监督学习相比,无监督学习既不会给出实验结果,也不会给出度量。根本没有人类来引导它。这很有挑战性。
人工神经网络(简称 ANN)是一种能解决所有这些问题的算法。本书只讨论使用 ANN 的监督学习。ANN 是一个受生物启发的 ML 模型,模仿人类大脑的运作。这是谈论深度学习(DL)时要涵盖的最重要的话题之一。理解只有几层和几个神经元的简单人工神经网络的操作,就更容易理解复杂模型是如何工作的。
在这一章中,将介绍学习 CNN 如何工作的先决条件。它从探索初级水平的人工神经网络开始。从知道它是线性模型的集合开始,你会发现它根本不是一个奇怪的概念;事实上,你已经知道了。本章讨论了一些与人工神经网络相关的概念,如学习率、反向传播和过拟合。本章将帮助你理解为什么我们需要人工神经网络中的学习率,以及它对训练是否有用。对单层感知器使用一个非常简单的 Python 代码,学习率值将被改变以捕捉它的想法,并注意改变学习率如何影响结果。它还讨论了反向传播算法如何用于更新人工神经网络的权重。本章还解释了过度拟合,这是对未知样本预测不佳的原因之一。一种基于回归的正则化技术通过简单的步骤来说明如何避免过拟合。人工神经网络有一个特殊的图表,使解释其结果更容易。本章绘制了数学表示及其图形,并探讨了令初学者感到困难的一点,即如何确定最佳的神经元数量和隐藏层。最后,给出了一个用人工神经网络进行 Python 分类的例子。
人工神经网络简介
监督学习问题分为两大类:分类和回归。回归输出是连续的数字,而分类输出是分类标签。每种类型的问题都可以使用线性或非线性模型。分类问题也可以分为二元或多类分类问题。所有这些类型的问题都可以使用神经网络来解决。也就是说,可以使神经网络产生连续或离散的输出。它可以处理二元或多类问题,并模拟线性和非线性函数。ANN 是一种通用函数逼近器(即 ANN 可以模拟任何线性和非线性函数的运算)。ANN 是一个参数模型,它有一组从问题中学习到的参数,如权重和偏差。它还有许多可以由工程师调整的超参数,例如学习速率和隐藏层数。
人工神经网络实际上由线性模型组成,这些模型被组合在一起以解决复杂的问题。下一小节讨论人工神经网络的基本构件实际上是一个线性模型。
线性模型是人工神经网络的基础
对于初学者来说,最简单的模型类型是线性模型。当然,每个人都知道线性模型,这使得接下来的解释更容易。我们可以从一个简单的回归问题开始,我们希望为表 2-1 中所示的样本创建一个线性模型。拟合此类数据的最佳线性模型是什么?让我们看看。
表 2-1
简单回归问题
|输入(X)
|
输出(Y)
|
| --- | --- |
| Two | six |
“线性模型”是指将每个输入映射到其相应输出的线。我们将从最简单的线性模型开始,如方程 2-1。该模型将输入和输出均衡在一起,方程中没有任何其他参数。
之后,我们创建了第一个模型。有人可能会问,构建任何模型的培训部分在哪里?答案是这个模型是非参数模型。“非参数”意味着模型没有可以从数据中学习的参数。因此,做这项工作不需要培训。在本章的后面,将添加一些参数。
Y = X (等式 2-1)
在常规的 ML 流水线中,在建立一个模型之后,我们必须测试它。在传统问题中,会有更多的样本,数据会被分成训练集和测试集。在训练模型之后,基于训练数据开始测试。如果它在训练数据上做得很好,那么我们可以在看不见的测试数据上逐步测试它。这是因为,如果一个模型不能很好地处理它所训练的数据,那么对于看不见的数据,情况可能会更糟。总之,我们的例子使我们摆脱了这样的工作,因为它只有一个样本,不需要培训。但是没有训练阶段并不意味着没有测试阶段。让我们基于这样一个样本来测试我们的模型。
测试阶段检查模型预测未知样本而非训练样本输出的准确性。基于 X=2 的示例,当应用于模型时,它也将返回 2。这是因为输入总是等于输出。除了预测和期望输出的位置,线性模型如图 2-1 所示。
图 2-1
非参数线性模型的预测和期望输出
我们可以简单地将期望输出和预测输出之间的差值作为等式 2-2。差值为 26 = 4。
误差 = 预测—期望(等式 2-2)
误差的存在意味着我们必须改变模型中的某些东西以减少误差。回头看看方程 2-1 中的模型,我们看到没有我们可以改变的参数。这个等式只有我们无法改变的输入和输出。因此,我们可以给这个等式添加一个参数,这有助于输入和输出之间的映射。方程 2-3 显示了修正的模型方程。
**Y = aX (方程式 2-3)
假设 a 的初始值为 1.5 。该方程在 2-3ʹ.方程中给出这样的线性模型如图 2-2 所示。
Y = 1.5 X (方程式 2-3’)
图 2-2
参数线性模型
请注意,添加参数后,模型现在是参数化模型。这是因为至少有一个参数需要从数据中学习。现在,在建立了新的模型之后,我们可以预测样本的输出。预测产量为 Y 预测 = 1.5(2) = 3。然后我们可以测量误差。根据等式 2-2,误差为 36 = 3。与先前的误差相比,与没有参数的先前模型相比,具有参数 a = 1.5 的新模型似乎增强了结果。但是预测仍然有误差,我们需要减少。
我们可以想象方程 2-1 中的第一个模型实际上是用方程 2-3 来表示的,但是参数总是设置为 1 。比较 a = 1 时产生的误差和a =1.5时的误差,也就是—4时的误差,似乎在a =1.5为 3 时误差减小了。人们可能想知道值 3 怎么会小于****4。答案是,误差中的负号只是说预测输出低于期望输出。差异量是误差的绝对值。也就是说,4的误差意味着期望输出与预测输出之间存在 4 的差值,并且期望输出低于预测输出,因为误差为负。注意,改变方程 2-2 中预测输出和期望输出的位置将改变误差的符号。现在让我们回到我们的问题上来。
****当 a = 1.5 时,结果比 a = 1.0 更好,这意味着增加该参数的值将减少误差。因此,我们知道变化的方向。我们试试用 a = 2.0。预测输出会是 Y 预测 = 2.0(2) = 4。这种情况下的误差将等于 46 = 2。误差比以前减少了很多。
根据前面的结果,我们可以推导出参数和误差之间的关系。使用 a = 1,误差为 4。将参数( a = 1.5)增加 0.5,误差减少 1.0 至 3。参数( a = 2.0)再加 0.5,误差减少 1.0,为 2。因此,将参数增加 0.5 会将误差减少 1.0 倍。因此,我们可以给参数加 1.0 来完全消除它,参数将是 a = 3.0。这种情况下的预测输出为 Y 预测 = 3.0(2) = 6。误差将为 66 = 0。误差现在为 0,当 a = 3.0 时,我们达到了最佳结果。
让我们对表 2-1 中的示例进行更改,除了使用新的示例之外,还将输出的输出从 6 更改为 6.5。基于等式 2-3,其中 a = 3.0,第一个样本的预测输出为 Y 预测 = (3.0)2 = 6.0,第二个样本的预测输出为Y**=(3.0)3 = 9.0。因此,总误差等于(6.0 6.5)+(9.0 9.5)= 1.0(表 2-2 )。如何减少这类错误?
*表 2-2
双样本回归问题
|输入(X)
|
输出(Y)
|
| --- | --- |
| Two | Six point five |
| three | Nine point five |
我们遵循的程序是改变参数的值,直到将误差减小到 0。表 2-3 显示了两个参数值的总误差。似乎 3.0 和大于等于 3.0 的值都不能消除误差。对应于 a = 2.5、 a = 3.0、 a = 3.5 的模型以及期望输出如图 2-3 所示。
图 2-3
多参数线性模型。虚线对应于 a=2.5 的模型,星号线对应于 a=3.5,实线对应于 a=3.0。
表 2-3
双样本回归问题
|参数
|
输出(Y)
|
预测
|
错误
|
总误差
|
| --- | --- | --- | --- | --- |
| Three point five | Six point five | Seven | 7.0−6.5=1.0 | Two |
| Nine point five | Ten point five | 10.5−9.5=1.0 |
| Two point five | Six point five | Five | 5.0−6.5=−1.5 | −4.0 |
| Nine point five | Seven point five | 7.5−9.5=−2.5 |
事实是,在我们的例子中,没有使误差等于 0 的参数值。我们想要一个乘以 2 得到 6.5,乘以 3 得到 9.5 的值。不可能找到这样的值。满足第一个样本的参数值为 a = 3.25,而对于第二个样本,参数值为 a = 3.17。因此,在模型的当前形式上,达到 0 的误差是不可能的。由于这个原因,偏差在解决这种情况中起着重要的作用。
我们可以像等式 2-4 一样,在等式 2-3 中添加一个偏差 b 。这种偏见能够解决我们的问题。
Y = aX + b (等式 2-4)
但是问题的复杂性现在增加了。我们试图找到两个参数(a,b)的值。基于之前的结果,当 a = 3.0 时,两个样本的预测输出分别为 6.0 和 9.0。预测输出比正确输出小 0.5。因此, b = 0.5 的值就是我们要找的。因此, a = 3.0 和 b = 0.5 将给出 0 的误差。这就是偏见如此重要的原因。
偏差允许我们在 y 轴上自由移动线性模型,同时增加拟合数据的可能性,而不仅仅是在 x 轴上移动它。请注意,它在我们的示例中非常有用,因为参数较少。当模型参数较多时,偏差可以忽略。
扩展表 2-2 中的示例,有一个新的输入 Z 添加到问题中,新数据在表 2-4 中。因为有两个输入和一个输出,等式 2-4 中的先前模型将不起作用,我们必须添加新的输入及其相关参数。等式 2-5 代表了新的模型。
表 2-4
双输入单输出回归问题
|输入(X)
|
输入(Z)
|
输出(Y)
|
| --- | --- | --- |
| Two | One point one | Six point five |
| three | Zero point eight | Nine point five |
Y=aX+cZ+b(等式 2-5)
现在,除了偏差 b 之外,我们还必须找到两个参数 a 和 c 的最佳值。之前使用的相同程序将应用于这个问题,以找到这些变量的最佳值。
通过创建简单的线性模型,我们已经成功地了解了人工神经网络的组成部分是如何工作的。人工神经网络由多个这样的线性模型组成,这些模型连接在一起以适应一个问题。以下部分将解释如何通过将线性模型连接在一起来设计网络。下一小节讨论如何为之前创建的模型绘制 ANN。
图形人工神经网络
人工神经网络是通过将多个线性模型连接在一起而构建的。随着每个模型中所需参数数量的增加,网络的完整方程变得过于复杂。因此,很难将问题表示为方程,但更简单的方法是将网络可视化为图形。网络图更容易理解和设计。在这里,我们将学习如何构建网络图,从线性模型开始。
ANN 是生物神经网络的人工表示。我们可以这样开始:人工神经网络的基本构件是人工神经元。在本章前面,我们也说过人工神经网络的基础是线性模型。因此,我们可以推断,神经元实际上是一个线性模型。与线性模型一样,神经元接受输入,进行一些处理,如乘法和加法,最后返回输出。图 2-4 显示了等式 2-4 中的线性模型和人工神经元之间的映射。注意,在线性模型中存在的所有变量也存在于 ANN 图中。这种人工神经网络被称为单层感知器。
图 2-4
从单输入线性模型到人工神经网络图的映射
我们可以从图形的核心开始,也就是带有文字“Math”的圆圈。这个圆圈代表神经网络的神经元。神经元是一个计算单元,它进行的计算类型是将每个输入乘以其相应的参数,将所有结果相加,然后返回表示乘积和(SOP)的输出。由于这个原因,输入 X 被连接到那个神经元。
因为每个参数必须与其输入相关联以计算 SOP,所以用于输入 X 的参数写在将其连接到神经元的箭头上方。使每个参数靠近其输入有助于找到与每个输入相关联的参数。这是针对输入及其参数的。基于当前的例子,这个想法可能不清楚,因为只有一个输入,但是稍后会更清楚。让我们转向偏见。
**在神经元之后使用新的块来将偏置 b 添加到 SOP。SOP 加上bT7 后,产生输出 Y 。到目前为止,一切都很好,但我们仍然可以使图形更简单。
在之前的讨论中,我们将偏差与输入区别对待。每个参数都乘以其输入,但偏差没有要乘以的输入。我们可以假设偏置有一个输入始终等于 + 1 。这大大简化了过程,因为我们可以消除神经元后添加的偏置模块,如图 2-5 所示。神经元将每个参数乘以其相关的输入,并同样对待偏差。它将被视为输入为 + 1 的参数。为了使偏差不同于常规参数,可以垂直添加偏差,同时在图表中水平添加其他参数。
图 2-5
从具有一个输入的线性模型映射到具有偏差的 ANN 图,通过将偏差与+1 的输入相关联,将偏差视为常规参数
根据前面的例子,我们知道如何从神经网络的角度绘制线性方程。现在,我们可以使用方程 2-5,其中有两个输入。唯一的变化是将新的输入 、Z 及其相关参数 、c 添加到图形中,类似于我们对输入 X 及其参数 a 所做的操作。新图如图 2-6 所示。对于每个新的输入,该过程重复进行。
图 2-6
从具有两个输入的线性模型到 ANN 图的映射
简而言之,人工神经网络中的神经元接受一组输入,将每个输入乘以相关参数,将乘法结果相加,最后返回输出。在人工神经网络中,神经元排列成三种类型的层:输入层、隐藏层和输出层。这种安排在生物神经网络中并不存在,但它有助于我们组织网络。图 2-7 显示了具有这三层的一般全连接(FC)人工神经网络的架构。该网络按照三层来组织。网络只有一个输入和输出层,但它可以有多个隐藏层。注意,每一层内的神经元都是根据它来命名的。也就是说,输入层内的神经元称为输入神经元,而隐藏神经元是隐藏层内的神经元。
图 2-7
通用 FC 人工网络架构
为简单起见,所有输入被赋予符号 X ,所有输出被赋予符号 O ,带有定义输入或输出的索引的下标。网络有 n 个 输入,其中X1是第一个输入,X5是第五个输入,依此类推,直到Xn它还有 m 个输入,其中 O 个 1 个 是第一个输入, O 个 5 个 是第五个输入,依此类推直到 O 个 m 个
****隐藏层中的神经元被赋予具有两个指数的符号,以反映其层指数以及在其层中的位置。例如,第一个隐藏层有 k 个神经元,其中是第一个隐藏层的第一个隐藏神经元,
是第二个隐藏层的第五个隐藏神经元,以此类推,直到
,是第 r th 隐藏层的第 p th 个隐藏神经元。
在每两层之间,有许多参数等于两层内神经元数量的乘积。例如,如果输入层有 n 个神经元,第一个隐层有 k 个神经元,那么连接它们所需的参数个数等于 n × k ,其中参数是指输入层中第 n 个神经元和第 k 个神经元之间的参数这个参数也可以称为权重,因为每个参数反映了其相关输入的重要性。参数值越大,其相关输入就越重要。
到目前为止,预计对 ANN 有一个基本的了解,但还需要了解更多。接下来的几节涵盖了一些关于人工神经网络的重要概念,这些概念对于人工神经网络的成功构建至关重要。
调整训练人工神经网络的学习率
新手学习 ann 的一个障碍是学习速度。我曾多次被问到学习速度对人工神经网络训练的影响。我们为什么要用学习率?学习率的最佳值是多少?在这一节中,我将用一个例子来说明学习率对于训练一个人工神经网络是多么有用,从而使事情变得简单。让我们从解释使用的例子开始。
过滤器示例
一个非常简单的例子可以让我们摆脱复杂性,专注于我们的目标,即学习率。这个例子用等式 2-6 表示。
(方程式 2-6)
如果输入等于或小于 250,那么输出将与输入相同。如果输入大于 250,那么它将被剪裁,输出将是 250。它的工作原理就像一个过滤器,只让 250 以下的输入通过,而将其他输入截止到 250。其图形如图 2-8 所示。
图 2-8
过滤器示例的激活功能
六个样本的数据如表 2-5 所示。
表 2-5
用于训练网络的数据,以过滤输入,了解学习率如何影响训练过程
|输入(X)
|
输出(Y)
|
| --- | --- |
| Sixty | Sixty |
| Forty | Forty |
| four hundred | Two hundred and fifty |
| Three hundred | Two hundred and fifty |
| -50 | -50 |
| -10 | -10 |
人工神经网络架构
所用人工神经网络的架构如图 2-9 所示。只有输入层和输出层。输入层只有一个神经元用于我们的单一输入。输出层只有一个神经元来产生输出。输出层神经元负责将输入映射到正确的输出。还有一个偏置施加到输出层神经元,值为 b ,输入为 +1 。还有一个用于输入的权重 W 。
图 2-9
与过滤器示例一起使用的 ANN 架构
激活功能
基于前面讨论的网络,我们只能近似线性函数,如图 2-1 。但是我们的问题使用了一个非线性函数,如图 2-8 所示。我们如何使用人工神经网络来表示这种类型的网络?本例中的解决方案是使用一个函数,如 ANN 中的激活函数。
人工神经网络可以逼近线性和非线性函数。人工神经网络将非线性纳入其计算的方式是通过激活函数。激活函数在 ANN 图中的位置是在 SOP 计算之后。在这种情况下,神经元的输出将是激活函数输出,而不仅仅是 SOP。这就是为什么在等式 2-6 中网络输出被设置为等于激活函数输出。
Python 实现
清单 2-1 中给出了实现整个网络的 Python 代码。在讨论了它的每个部分并使其尽可能简单之后,我们将集中讨论改变学习率如何影响网络训练。
1 import numpy
2
3 def activation_function(inpt):
4 if(inpt > 250):
5 return 250 # clip the result to 250
6 else:
7 return inpt # just return the input
8
9 def prediction_error(desired, expected):
10 return numpy.abs(numpy.mean(desired-expected)) # absolute error
11
12 def update_weights(weights, predicted, idx):
13 weights = weights + 0.00001*(desired_output[idx] - predicted)*inputs[idx] # updating weights
14 return weights # new updated weights
15
16 weights = numpy.array([0.05, .1]) #bias & weight of input
17 inputs = numpy.array([60, 40, 100, 300, -50, 310]) # training inputs
18 desired_output = numpy.array([60, 40, 150, 250, -50, 250]) # training outputs
19
20 def training_loop(inpt, weights):
21 error = 1
22 idx = 0 # start by the first training sample
23 iteration = 0 #loop iteration variable
24 while(iteration < 2000 or error >= 0.01): #while(error >= 0.1):
25 predicted = activation_function(weights[0]*1+weights[1]*inputs[idx])
26 error = prediction_error(desired_output[idx], predicted)
27 weights = update_weights(weights, predicted, idx)
28 idx = idx + 1 # go to the next sample
29 idx = idx % inputs.shape[0] # restricts the index to the range of our samples
30 iteration = iteration + 1 # next iteration
31 return error, weights
32
33 error, new_weights = training_loop(inputs, weights)
34 print('--------------Final Results----------------')
35 print('Learned Weights : ', new_weights)
36 new_inputs = numpy.array([10, 240, 550, -160])
37 new_outputs = numpy.array([10, 240, 250, -160])
38 for i in range(new_inputs.shape[0]):
39 print('Sample ', i+1, '. Expected = ', new_outputs[i], ' , Predicted = ', activation_function(new_weights[0]*1+new_weights[1]*new_inputs[i]))
Listing 2-1Adjusting Learning Rate for Successful ANN Training
第 17 行和第 18 行负责创建两个数组(inputs 和 desired_output ),用于保存我们示例中的训练输入和输出数据。第 16 行创建了一个网络参数数组,它们是输入参数和偏差。它们被随机初始化为 0.05 的偏置和 0.1 的输入。第 3 行到第 7 行使用 activation_function(inpt)方法实现激活函数本身。它接受作为输入的单个参数,并返回作为网络预测输出的单个值。
因为预测可能会有误差,所以我们需要测量一下,才能知道我们离正确的预测有多远。因此,在第 9 行和第 10 行中实现了一个名为 prediction_error(desired,expected)的方法,它接受两个输入:期望的和预测的输出。该方法只是计算每个期望输出和预测输出之间的绝对差值。任何误差的最佳值肯定是 0。这是最佳值。
如果出现预测误差怎么办?在这种情况下,我们必须对网络进行更改。但是到底要改变什么呢?必须改变的是网络参数。为了更新网络参数,在第 13 和 14 行中定义了一个名为 update_weights(weights,predicted,idx)的方法。它接受三个输入:旧权重、预测输出和具有错误预测的输入的索引。等式 2-7 用于更新权重。
(方程式 2-7)
在哪里
-
η–学习率
-
d–期望输出
-
Y–预测输出
-
X–输入
-
W(n)–当前重量
-
W(n+1)——更新权重
该等式使用当前步骤 n 的权重来生成下一步骤的权重( n + 1 )。这个等式有助于我们理解学习速度是如何影响学习过程的。
最后,我们需要将所有这些连接在一起,使网络能够学习。这是使用从第 20 行到第 31 行定义的 training_loop(inpt,weights)方法完成的。它进入一个训练循环。该循环用于以最小的可能预测误差将输入映射到它们的输出。该循环执行三项操作:
-
产量预测。
-
错误计算。
-
更新权重。
既然我们已经对这个例子和它的 Python 代码有了一个概念,现在让我们来看看学习率对于获得最佳结果是如何有用的。
学习率
在前面讨论的清单 2-1 的例子中,第 13 行有权重更新等式,其中使用了学习率。让我们从等式中去掉学习率。具体如下:
weights = weights + (desired_output[idx] - predicted)*inputs[idx]
我们来看看去掉学习率的效果。在训练循环的第一次迭代中,网络的偏差和权重的初始值分别为 0.05 和 0.1。输入是 60,期望输出是 60。第 25 行的预期输出,即激活函数的结果,将是 activation _ function(0.05(+1)+0.1(60))。预测产量为 be 6.05。在第 26 行,通过得到期望输出和预测输出之间的差来计算预测误差。误差为 ABS(60 6.05)= 53.95。然后在第 27 行,权重将根据前面的等式进行更新。新的权重是[0.05,0.1] + (53.95)*60 = [0.05,0.1] + 3237 = [3237.05,3237.1]。似乎新的权重与以前的权重相差太大。每个重量增加了 3,237,这太大了。但是让我们继续做下一个预测。
在下一次迭代中,网络将拥有这些数据(b=3237.05,W=3237.1,输入=40,期望输出= 40)。预期输出将是 activation _ function((3237.05+3237.1(40))= 250。预测误差将为 ABS(40250)= 210。误差非常大。这个误差比之前的 53.95 要大。因此,我们必须再次更新权重。根据上式,新权重为[3237.05,3237.1]+(210)* 40 =[3237.05,3237.1]+8400 =[5162.95,5162.9]。表 2-6 总结了前三次迭代的结果。
表 2-6
训练滤波器网络的前三次迭代的结果
|预报
|
错误
|
更新值
|
新重量
|
| --- | --- | --- | --- |
| Six point zero five | Fifty-three point nine five | 3237.0 | [3237.05, 3237.1] |
| Two hundred and fifty | Two hundred and ten | —8400 | [–5162.95, –5162.9 ] |
| −521452.95 | Five hundred and twenty-one thousand five hundred and fifty-two point nine five | 52155295.0 | [52150132.04999999, 52150132.09999999] |
| −2555356472.95 | Two billion five hundred and fifty-five million three hundred and fifty-six thousand four hundred and twenty-two point nine five | —127767821147.0 | [–1.27715671 e+11,–1.27715671 e+11] |
随着我们进行更多的迭代,结果会变得更糟。权重的大小变化很快,有时甚至改变符号。它们从非常大的正值变为非常大的负值。我们怎样才能阻止这些巨大而突然的重量变化呢?如何缩小权重更新的值?
如果我们从表 2-6 中查看重量变化的值,该值似乎非常大。这意味着网络高速改变其权重。我们只需要让它慢下来。如果我们能够降低这个值,那么一切都会好的。但是怎么做呢?回到代码,看起来更新等式是生成如此大的值的原因,特别是这一部分:
(desired_output[idx] - predicted)*inputs[idx]
我们可以通过将其乘以一个小值(如 0.1)来缩放该部分。因此,在第一次迭代中,不是生成 3237.0 作为更新值,而是减少到 323.7。我们甚至可以将这个值降低到 0.001。使用 0.001,更新值仅为 3.327。
我们现在可以抓住它了。这个值就是学习率。为学习率选择小的值使得权重更新的速率更小,并且避免突然的变化。值越大,变化越快,这会产生不好的结果。
但是对于学习率来说什么是最好的 值 ?
对于学习率来说,没有一个特定的值可以说是最佳值。学习率是一个超参数。超参数的值由实验确定。我们尝试不同的值,并使用给出最佳结果的值。
测试网络
对于我们的问题,使用值. 00001 就可以了。用那个学习率训练完网络,就可以做个测试了。表 2-7 显示了四个新测试样本的预测结果。使用学习率后,现在的结果似乎好了很多。
表 2-7
测试样本预测结果
|投入
|
输出量的希望值
|
预测产量
|
| --- | --- | --- |
| Ten | Ten | Ten point eight seven |
| Two hundred and forty | Two hundred and forty | Two hundred and thirty-nine point one three |
| Five hundred and fifty | Two hundred and fifty | Two hundred and fifty |
| –160 | –160 | –157.85 |
现在我们能够理解学习速度决定了我们前进的步伐。步长越大,变化越突然。我们可能接近最佳解,只需要稍微改变我们的参数来达到它,但是忽略或使用学习率的坏值会使我们远离解。
使用反向传播的权重优化
在上一节中,我们使用学习率来更新人工神经网络的权重。在本节中,我们将使用反向传播算法来完成这项工作,并推导出它如何优于仅使用学习率。用两个例子对算法进行了数值说明。
本节不会直接深入反向传播算法的细节,而是从训练一个非常简单的网络开始。这是因为反向传播算法意味着在训练后应用于网络。因此,我们应该在应用它之前训练网络,以获得反向传播算法的好处以及如何使用它。读者应该对人工神经网络的工作原理、偏导数和多元链式法则有一个基本的了解。
无隐层神经网络的反向传播
从一个简单的例子开始,图 2-10 显示了它的网络结构,我们将用它来解释反向传播算法是如何工作的。它只有两个输入,符号化为X1和X2。输出层只有一个神经元,没有隐藏层。每个输入都有相应的权重其中W1和W2是权重为1和X2输出层神经元有一个偏置,值为 b ,固定输入值为 + 1 。
**
图 2-10
训练和应用反向传播的网络结构
输出层神经元使用由等式 2-8 定义的 sigmoid 激活函数:
(方程式 2-8)
其中 s 为每个输入与其对应权重之间的 SOP。 s 是激活函数的输入,在本例中,如等式 2-9 所定义。
s=【w】
****表 2-8 显示了用作训练数据的单个输入及其相应的期望输出。这个例子的基本目标不是训练网络,而是理解如何使用反向传播来更新权重。现在,为了集中于反向传播,我们将分析单个数据记录。
表 2-8
第一反向传播示例的训练数据
|X?? 1
|
X2
|
期望输出
|
| --- | --- | --- |
| 0.1 | 0.3 | 0.03 |
假设权重和偏差的初始值如表 2-9 所示。
表 2-9
网络的初始参数
|W1
|
W2
|
b
|
| --- | --- | --- |
| 0.5 | 0.2 | 1.83 |
为简单起见,所有输入、权重和偏差的值将被添加到网络图中,如图 2-11 所示。
图 2-11
添加了输入和参数的第一反向传播示例的网络
现在,让我们训练网络,看看是否会根据当前的权重和偏差返回所需的输出。激活函数的输入将是每个输入与其权重之间的 SOP。然后,偏差将被添加到总数中,如下所示:
激活函数的输出将通过将先前计算的 SOP 应用于所用函数(sigmoid)来计算,如下所示:
激活函数的输出反映了当前输入的预测输出。很明显,期望输出和期望输出之间存在差异。但是这种差异的来源是什么呢?应该如何改变预测的输出以更接近期望的结果?这些问题后面会回答。但至少,让我们看到我们的神经网络基于一个误差函数的误差。
误差函数表明预测输出与期望输出的接近程度。误差的最佳值为零,这意味着根本没有误差,并且期望的和预测的结果是相同的。误差函数之一是平方误差函数,如等式 2-10 所示。
(方程式 2-10)
注意,加在方程上的是为了以后简化导数。我们可以按如下方式测量网络误差:
结果保证了大误差的存在( ~0.357 )。这是错误所告诉我们的。它只是给了我们一个指示,告诉我们预测的结果离期望的结果有多远。既然我们知道如何测量误差,我们需要找到一种方法来最小化它。我们唯一能玩的参数是重量。我们可以尝试不同的权重,然后测试我们的网络。
权重更新方程
权重可以根据等式 2-7(用于上一节)来改变,其中
-
n :训练步骤(0,1,2,…)。
-
W(n):当前训练步的重量。
-
【w】(=【b】【n】-我...。,【w】【m】)]******
***** η :网络学习率。
* ***d***(***n***):期望输出。
* ***Y***(***n***):预测产量。
* ***X***(***n***):网络做出错误预测的当前输入。****
****对于我们的网络,这些参数具有以下值:
-
n : 0
-
W(n):【1.83,0.5,0.2】
-
η :超参数。比如我们可以选择 0.01。
-
d(n):【0.03】。
-
Y(n):【0.874352143】。
-
X(n):[+1,0.1,0.3]。第一个值(+1)是偏差。
我们可以基于前面的等式更新我们的神经网络权重:
新的重量在表 2-10 中给出。
表 2-10
第一反向传播示例的网络的更新权重
|W 1 新
|
W 2 新
|
b 新
|
| --- | --- | --- |
| 0.197466943 | 0.499155648 | 1.821556479 |
基于新的权重,我们将重新计算预测输出,并继续更新权重和计算预测输出,直到达到手头问题的可接受误差值。
这里,我们成功地更新了权重,而没有使用反向传播算法。我们还需要那个算法吗?是的。接下来将解释原因。
为什么反向传播算法很重要?
对于最佳情况,假设权重更新方程产生最佳权重;现在还不清楚这个函数实际上做了什么。它就像一个黑匣子,因为我们不了解它的内部运作。我们只知道,万一出现分类错误,我们应该应用这个等式。然后,该函数将生成新的权重,用于接下来的训练步骤。但是为什么新的权重更擅长预测呢?每个权重对预测误差有什么影响?增加或减少一个或多个权重如何影响预测误差?
需要更好地理解如何计算最佳权重。为此,我们应该使用反向传播算法。它帮助我们理解每个权重如何影响 NN 总误差,并告诉我们如何将误差最小化到非常接近零的值。
向前传球和向后传球
在训练一个神经网络时,有前向和后向两个通道,如图 2-12 。第一遍永远是正向传递,输入应用到输入层,向输出层移动,计算输入和权重之间的 SOP,应用激活函数生成输出,最后计算预测误差,就知道当前网络的精度有多高。
图 2-12
神经网络训练的前向和后向途径
但是如果有预测误差呢?我们应该修改网络以减少错误。这是在反向传递中完成的。在前向传递中,我们从输入开始,直到计算预测误差。但是在反向传递中,我们从错误开始,直到到达输入。这一步的目标是了解每个权重如何影响总误差。知道权重和误差之间的关系允许我们修改网络权重以减小误差。比如在后向传递中,我们可以得到有用的信息,比如将W1的当前值增加 1.0,预测误差就会增加 0.07。这有助于我们理解如何选择【W】1的新值,以使误差最小化(W1不应增加)。
偏导数
向后传递中使用的一个重要操作是计算导数。在开始计算反向传递中的导数之前,我们可以从一个简单的例子开始,让事情变得简单一些。
对于一个多元函数,如Y=X2Z+H,给定变量 X 的变化对输出 Y 有什么影响?这个问题用偏导数来回答。它是这样写的:
注意,除了 X 之外的一切都被视为常数。这就是为什么 H 在计算偏导数后被替换为 0。这里, ∂X 表示变量 X 的微小变化, ∂Y 表示 Y 的微小变化。 Y 的变化是 X 变化的结果。通过对 X 做一个很小的改动,对 Y 有什么影响?微小的变化可以是增加或减少一个微小的值,例如 0.01。通过代入不同的 X 值,我们可以发现 Y 相对于 X 如何变化。
将遵循相同的程序,以便了解 NN 预测误差如何相对于网络权重的(wrt)变化而变化。所以,我们的目标是计算和
,因为我们只有两个权重:W1和W2。我们来计算一下。
预测误差权重的变化
看这个等式,Y = X2Z+H,计算偏导数似乎很简单,因为有一个等式同时关联 Y 和 X 。但是在预测误差和权重之间没有直接的等式。这就是为什么我们要用多元链式法则来求 Y wrt X 的偏导数。
重量链的预测误差
让我们试着找出预测误差与权重之间的联系。预测误差是根据等式 2-10 计算的。
但是这个方程没有任何权重。没问题:我们可以按照前一个方程的每个输入进行计算,直到我们得到权重。期望的输出是一个常数,因此不可能通过它达到权重。预测输出基于 sigmoid 函数计算,如等式 2-8 所示。
同样,计算预测产量的等式没有任何权重。但是仍然有变量 s (SOP),根据等式 2-11,其计算已经依赖于权重。
s=【w】
****图 2-13 显示了计算重量时应遵循的计算链。
图 2-13
从预测误差开始计算权重的计算链
因此,要知道预测误差如何随权重的变化而变化,我们应该做一些中间运算,包括找出预测误差如何随预测输出的变化而变化。然后,我们需要找到预测产量和 SOP 之间的关系。最后,我们将通过改变权重来发现 SOP 是如何变化的。有如下四个中间偏导数:
、
、
和
这个链将最终告诉预测误差如何随着每个权重的变化而变化,这是我们的目标,通过将所有单独的偏导数相乘,如下所示:
重要说明
目前,还没有将预测误差与网络权重直接联系起来的等式,但是我们可以创建一个将它们联系起来的等式,并对其直接应用偏导数。这是等式 2-12。
(方程式 2-12)
因为这个方程看起来很复杂,为了简单起见,我们可以使用多元链式法则。
计算链偏导数
让我们计算之前创建的链的每个部分的偏导数。
误差预测输出偏导数:
通过值替换,
预测产量- SOP 偏导数:
请记住,商法则可用于计算 sigmoid 函数的导数,如下所示:
通过值替换,
SOP-W1偏导数:
通过值替换,
SOP-W2偏导数:
通过值替换,
在计算出每个单独的导数后,我们可以将它们相乘,从而得到预测误差和每个权重之间的关系。
预测误差-W1偏导数:
预测误差-W2偏导数:
最后,有两个值反映预测误差如何相对于权重变化(对于 W 1 为 0.009276093,对于 W 2 为 0.027828278)。但这意味着什么呢?结果需要解释。
解释反向传播的结果
从下面得到的最后两个导数中的每一个都有两个有用的结论:
-
导数符号
-
导数大小
如果导数为正,这意味着增加权重会增加误差,同样,减少权重会减少误差。如果导数是负的,那么增加权重将减少误差,相应地,减少权重将增加误差。
但是误差会增加或减少多少呢?DM 可以告诉我们。对于正导数,增加 p 的权重会增加DM∫p的误差。对于负导数,增加权重 p 将减少误差DM∫p。
因为求导的结果是正的,这意味着如果W1增加 1 那么总误差将增加 0.009276093。同样,因为
导数的结果是正的,这意味着如果2增加 1,那么总误差将增加 0.027828278。
**#### 更新权重
在成功计算出误差相对于每个单独权重的导数之后,我们可以更新权重以增强预测。每个权重将根据其导数进行更新,如下所示:
对于第二重量,
注意,导数是减去的,而不是加到重量上,因为它是正的。
然后,继续预测和更新权重的过程,直到产生具有可接受误差的期望输出。
隐层神经网络的反向传播
为了使思路更加清晰,我们可以在添加一个具有两个神经元的隐藏层之后,在下面的 NN 上应用反向传播算法。新网络如图 2-14 所示。
图 2-14
第二反向传播示例的网络架构
先前使用的相同输入、输出、激活函数和学习率也将应用于本例。以下是网络的完整权重:
|W1
|
W2
|
W3
|
W4
|
W5
|
W6
|
b1
|
b2
|
b3
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 0.5 | 0.1 | 0.62 | 0.2 | —0.2 | 0.3 | 0.4 | —0.1 | 1.83 |
图 2-15 显示了添加了所有输入和权重的先前网络。
图 2-15
在添加输入和参数值之后的第二反向传播示例的网络架构
首先,我们应该通过正向传递来获得预测的输出。如果预测有错误,那么我们应该根据反向传播算法通过反向传递来更新权重。让我们计算隐层中第一个神经元的输入(1):
**
对隐层中第二个神经元的输入(2):
**
隐藏层的第一个神经元的输出:
以及隐藏层的第二神经元的输出:
下一步是计算输出神经元的输入:
输出神经元的输出:
因此,基于当前权重,我们的神经网络的预期输出是 0.865。然后,我们可以根据以下等式计算预测误差:
误差似乎非常大,因此我们应该使用反向传播算法来更新网络权重。
偏导数
我们的目标是得到总误差 E 如何改变 wrt 的六个权重(W1:W6):
、
、
、
、
、
让我们从计算隐藏输出层权重的输出的偏导数开始(W5和W6)。
EWT55偏导数:**
从W5开始,我们将遵循这样的链条:
我们可以首先计算每个单独的部分,然后将它们组合起来得到所需的导数。
对于一阶导数:
通过替换这些变量的值,
对于二阶导数:
对于最后一个导数:
计算完所有三个所需的导数后,我们可以计算目标导数,如下所示:
E—W6偏导数:
为了计算,我们将使用以下链:
将重复相同的计算,仅改变最后一个导数。它可以计算如下:
最后,可以计算导数:
这是给 W 5 和W6 的。我们来计算一下对W1 到W4 的导数 wrt。
E—WT51偏导数:**
从 W 1 开始,我们将遵循这个链条:
我们将按照前面的程序,计算每个单独的导数,最后将它们全部组合起来。前面已经计算了前两个导数,结果如下:
对于下一个导数:
对于:
对于:
最后,可以计算目标导数:
E—W2偏导数:
类似于计算的方法,我们可以计算
。唯一的变化将是在最后一个衍生物
。
然后:
后两个权重( W 3 和 W 4 )的计算方法与 W 1 和 W 2 类似。
E—WT53偏导数:**
从 W 3 开始,我们应该遵循这个链条:
需要计算的缺失导数是。
对于:
对于:
最后,我们可以计算所需的导数,如下所示:
EWT54偏导数:**
我们现在可以类似地计算:
我们应该计算缺失的导数:
然后计算:
更新权重
至此,我们已经成功地根据网络中的每个权重计算出了总误差的导数。下一步是根据导数更新权重并重新训练网络。更新后的权重将按如下方式计算:
过度拟合
您是否曾经创建过一个 ML 模型,它对于训练样本来说是完美的,但是对于看不见的样本却给出了非常糟糕的预测?你想过为什么会这样吗?原因可能是过度拟合。有过拟合问题的模型对训练样本的预测很好,但对验证数据的预测很差。这是因为该模型使其自身适应训练数据中的每条信息,直到收集到只能在训练数据中找到的一些属性。让我们试着理解这个问题。
ML 的重点是用训练数据训练算法,以便创建能够对看不见的数据(测试数据)做出正确预测的模型。例如,为了创建分类器,人类专家将从收集训练 ML 算法所需的数据开始。人类负责寻找最佳类型的特征,这些特征能够区分不同的类别,以便代表每个类别。这些特征将用于训练 ML 算法。假设我们要建立一个 ML 模型,将图 2-16 中的图像分类为包含或不包含猫。
图 2-16
训练模型的猫的图像
我们要回答的第一个问题是“使用什么功能最好?”这是 ML 中的一个关键问题,因为使用的特征越好,训练的 ML 模型做出的预测就越好,反之亦然。让我们试着将这些图像形象化,并提取一些代表猫的特征。一些代表性的特征可能是存在两个深色的瞳孔和两个对角线方向的耳朵。让我们假设我们已经以某种方式从前面的训练图像中提取了特征,并且已经创建了训练的 ML 模型。这个模型可以处理各种各样的猫图像,因为所使用的特征存在于大多数猫中。我们可以使用一些看不见的数据来测试模型,如图 2-17 所示。假设测试数据的分类准确率为 x% 。
图 2-17
猫的测试图像
人们可能希望提高分类精度。首先要考虑的是使用比以前更多的功能。这是因为使用的区别特征越多,准确度就越高。通过再次检查训练数据,我们可以发现更多的特征,例如整体图像颜色,因为所有训练猫样本都是白色的,而训练数据中的虹膜颜色是黄色的。特征向量将具有这四个特征:
-
深色瞳孔
-
对角耳朵
-
白色毛皮
-
黄色鸢尾花
它们将用于重新训练 ML 模型。
创建训练好的模型后,下一步是测试它。使用新的特征向量后的预期结果是分类精度将下降到小于 x% 。但是为什么呢?准确性下降的原因是使用了一些已经存在于训练数据中但并非普遍存在于所有 cat 图像中的特征。这些特征并不是所有猫图像的共性。在检测数据中,有些猫的皮毛是黑色或黄色的,而不是训练中使用的白色皮毛。
在我们的例子中,所使用的特征对于训练样本来说是强有力的,但是对于测试样本来说是非常差的,这可以被描述为过度拟合。该模型用一些特征来训练,这些特征是训练数据所独有的,但不存在于测试数据中。
前面讨论的目的是通过使用一个高层次的例子来简化过度拟合的概念。要了解细节,最好用一个更简单的例子。这就是为什么接下来的讨论将基于回归示例。
基于回归示例理解正则化
假设我们想要创建一个回归模型来拟合图 2-18 中所示的数据。我们可以使用多项式回归。
图 2-18
拟合回归模型的数据
我们可以从一个一次多项式方程的线性模型开始,如方程 2-13 所示。
y1= f1(x)=θ1x+θ0(等式 2-13)
其中θ0和θ1是模型参数, x 是唯一使用的特征。
前一型号的图如图 2-19 所示。
图 2-19
使用一级模型拟合数据的初始模型
根据损失函数,如等式 2-14 中的损失函数,我们可以得出结论,该模型不适合数据。
(方程式 2-14)
其中 fI(xI是样本 i 的期望输出,d i 是同一样本的期望输出。
模型过于简单,有很多预测都不准确。由于这个原因,我们应该创建一个更复杂的模型,它可以很好地拟合数据,我们可以将方程的次数从一次增加到二次,如方程 2-15 所示。
y2= f1(x)=θ2x2+θ1x+θ0(等式 2-15)
通过使用相同的特征 x 的 2 次方(x 2 ),我们创建了一个新的特征,我们将不仅捕获数据的线性属性,还捕获一些非线性属性。新模型的图形如图 2-20 所示。
图 2-20
使用更多特征来创建二次模型
该图显示,二次多项式比一次多项式更适合数据。但是二次方程也不太适合一些数据样本。这就是为什么我们可以用方程 2-16 建立一个更复杂的三次模型。该图如图 2-21 所示。
图 2-21
三次模型
y3= f3(x)=θ3x3+2x2
可以注意到,在添加了捕获三次数据属性的新特征之后,模型更好地拟合了数据。为了比以前更好地拟合数据,我们可以将方程的次数增加到四次,如方程 2-17 所示。该图如图 2-22 所示。
y4= f4(x)=θ4x4+3x3
图 2-22
四阶模型
似乎多项式方程的次数越高,就越符合数据。但是有一些重要的问题需要回答。如果通过添加新特征来增加多项式方程的次数可以增强结果,为什么不应该使用非常高的次数,例如 100 次次次?对于一个问题,用什么度最好?
模型容量/复杂性
术语“模型容量/复杂性”指的是模型可以处理的变化程度。容量越高,模型能够应对的变化就越多。第一款 y 1 据说比 y 4 容量小。在我们的例子中,容量随着多项式次数的增加而增加。
当然,多项式方程的次数越高,它就越适合数据。但是请记住,增加多项式次数会增加模型的复杂性。使用容量高于所需容量的模型可能会导致过度拟合。该模型变得非常复杂,并且非常适合训练数据,但是不幸的是对于看不见的数据来说非常弱。ML 的目标是创建一个模型,该模型不仅对训练数据而且对看不见的数据样本都是健壮的。
四度(y 4 的模型很复杂。是的,它很好地适应了可见的数据,但是对于不可见的数据却不是这样。对于这种情况,y 4 中新使用的特征,即x4,捕获了比所需更多的细节。因为这个新特性使得模型过于复杂,我们应该去掉它。
在这个例子中,我们实际上知道要删除哪些特性。所以,我们可以去掉它们,回到之前的三次模型(θ4x4+θ3x3+θ2x2+θ1x+θ0)。但是在实际工作中,我们不知道要删除哪些特征。此外,假设新特性不太糟糕,我们不想完全删除它,只想惩罚它。我们做什么呢
回头看损失函数,唯一的目标是最小化/惩罚预测误差。我们可以设定一个新的目标,尽可能地最小化/惩罚新特性x?? 4 的影响。在修改损失函数来惩罚 x 3 之后,新的在等式 2-18 中。
(方程式 2-18)
我们现在的目标是最小化损失函数。我们现在只对最小化这一项θ4x4感兴趣。显然,为了最小化θ4x4,我们应该最小化θ4,因为它是我们唯一可以改变的自由参数。如果我们想完全去掉这个特征,以防它是一个非常糟糕的特征,我们可以把它的值设置为零,如等式 2-19 所示。
(方程式 2-19)
去掉它,我们回到三次多项式方程(y 3 )。y 3 并不像 y 4 那样完美地拟合可见数据,但一般来说,它会比 y 4 对不可见数据给出更好的性能。
但是如果 x 4 是一个相对较好的特征,我们只是想惩罚它而不是完全删除它,我们可以将它设置为一个接近但不为零的值(比如 0.1),如等式 2-20 所示。通过这样做,我们限制了 x 4 的影响。因此,新模型不会像以前那样复杂。
(方程式 2-20)
回到 y 2 ,好像比 y 3 简单。它可以很好地处理可见和不可见的数据样本。所以,我们应该删除 y 3 中使用的新功能,也就是 x 3 ,或者如果它做得相对好就惩罚它。我们可以修改损失函数来做到这一点,如方程 2-21 所示。
(方程式 2-21)
L1 正则化
注意,我们实际上知道 y 2 是拟合数据的最佳模型,因为数据图对我们来说是可用的。这是一个非常简单的任务,我们可以手动解决。但是,如果我们无法获得这些信息,随着样本数量和数据复杂性的增加,我们将无法轻易得出这样的结论。必须有一些自动的东西来告诉我们哪种程度适合数据,并告诉我们要惩罚哪些特征来获得对看不见的数据的最佳预测。这就是正规化。
正则化有助于我们选择适合数据的模型复杂度。自动惩罚使模型过于复杂的特征是很有用的。请记住,如果特征不差,正则化是有用的,它将帮助我们在相对意义上获得良好的预测;我们只需要惩罚他们,而不是完全消除他们。正则化会惩罚所有使用的要素,而不是选定的子集。之前,我们只惩罚了两个特征,x 4 和 x 3 ,而不是所有的特征。但正规化就不是这样了。
使用正则化,一个新的项被添加到损失函数中以惩罚特征,因此损失函数将如等式 2-22 所示。
(方程式 2-22)
将λ移出总和后,也可以写成等式 2-23。
(方程式 2-23)
新增加的术语用于惩罚特征,以控制模型的复杂程度。在加入正则项之前,我们之前的目标是尽可能减小预测误差。现在我们的目标是最小化误差,但要小心不要让模型太复杂,避免过度拟合。
有一个称为 lambda (λ)的正则化参数,用于控制如何惩罚要素。它是一个没有固定值的超参数。它的值根据手头的任务是可变的。随着其值的增加,将有更高的惩罚功能。因此,模型变得更简单。当它的值降低时,特征的惩罚将降低,因此模型复杂性增加。值为零表示完全不移除特征。
当λ为零时,那么θj的值根本不会被罚,如下式所示。这是因为将λ设置为零意味着去除正则化项,只留下误差项。因此,我们的目标将返回到将误差最小化到接近于零。当以误差最小化为目标时,模型可能会过拟合。
但是当惩罚参数λ的值很高时(比如 10 9 ,那么为了使损失保持在最小值,参数θj必须有很高的惩罚。因此,参数θj将为零。因此,模型(y 4 )的θI将被修剪,如下所示。
请注意,正则项从 1 开始其索引 j 不为零。实际上,我们用正则项来惩罚特征(xIT5)。因为θ0没有关联特征,所以没有理由惩罚它。这种情况下,模型为 y4=θ0,图形如图 2-23 所示。
图 2-23
惩罚所有特征后平行于 x 轴的模型
设计人工神经网络
人工神经网络的初学者可能会问一些问题,包括以下问题:使用多少个隐藏层是正确的?每个隐藏层有多少个隐藏神经元?使用隐藏层/神经元的目的是什么?增加隐藏层/神经元的数量是否总能得到更好的结果?我很高兴地说,我们可以回答这些问题。明确地说,如果要解决的问题很复杂,回答这样的问题可能会太复杂。在本节结束时,你至少可以知道如何回答这些问题,并能够通过简单的例子来测试自己。我们开始吧。
ANN 的灵感来自于生物神经网络。为简单起见,在计算机科学中,它被表示为一组层。这些层分为三类:输入、隐藏和输出。
知道输入和输出层的数量及其神经元的数量是最容易的部分。每个网络都有单一的输入和输出层。输入层中神经元的数量等于正在处理的数据中输入变量的数量。输出层中神经元的数量等于与每个输入相关联的输出的数量。但是挑战在于知道隐藏层及其神经元的数量。
以下是学习分类问题中隐藏层和每个隐藏层中神经元数量的一些指导原则:
-
根据这些数据,画出一个预期的决策边界来分隔这些类。
-
将决策边界表示为一组线。请注意,这些线的组合必须服从决策边界。
-
选定线的数量表示第一个隐藏层中隐藏神经元的数量。
-
为了连接由前一层创建的线,添加了一个新的隐藏层。请注意,每次需要在前一个隐藏层中的线条之间创建连接时,都会添加一个新的隐藏层。
-
每个新隐藏层中隐藏神经元的数量等于要建立的连接的数量。
为了让事情更清楚,让我们将前面的准则应用到几个例子中。
示例 1:无隐藏层的人工神经网络
先说一个简单的两类分类问题的例子,如图 2-24 所示。每个样本有两个输入和一个表示类标签的输出。这与异或问题非常相似。
图 2-24
两类分类问题
要回答的第一个问题是是否需要隐藏层。确定这一点要遵循的规则如下:
- 在人工神经网络中,当且仅当数据必须非线性分离时,才需要隐藏层。
查看图 2-25 ,似乎类别必须非线性分离。单行不行。因此,我们必须使用隐藏层,以获得最佳决策边界。在这种情况下,我们可能仍然不使用隐藏层,但这会影响分类精度。所以,最好使用隐藏层。
知道我们需要隐藏层之后,我们需要回答两个重要的问题。这些问题如下:
-
所需的隐藏层数是多少?
-
每个隐藏层中隐藏神经元的数量是多少?
按照前面的过程,第一步是绘制一个划分两个类的决策边界。正确拆分数据的可能决策边界不止一个,如图 2-25 所示。我们将用于进一步讨论的是图 2-25(a) 。
图 2-25
非线性分类问题不能用一条线来解决
按照指导方针,下一步是用一组线来表达决策边界。
使用一组线来表示决策边界的想法来自于这样一个事实,即任何人工神经网络都是使用单层感知器作为构建块来构建的。单层感知器是一个线性分类器,它使用根据等式 2-24 创建的线来分离类别。
和=【w】x**
其中xI是输入的,wI是其权重, b 是偏差,而因为每个添加的隐藏神经元都会增加权重的数量,所以建议使用完成任务的最小数量的隐藏神经元。使用比所需更多的隐藏神经元将增加更多的复杂性。**
**回到我们的例子,说 ANN 是使用多个感知器网络构建的,等同于说网络是使用多条线构建的。
在这个例子中,判定边界由一组线代替。这些线从边界曲线改变方向的点开始。此时,放置了两条线,每条线的方向都不同。
因为边界曲线只有一个点改变方向,如图 2-26 中灰色圆圈所示,那么就只需要两条线。换句话说,有两个单层感知器网络。每个感知器产生一条线。
图 2-26
对问题进行分类需要两行
知道只需要两条线来表示决策边界告诉我们,第一个隐藏层将有两个隐藏神经元。
到目前为止,我们有一个带有两个隐藏神经元的隐藏层。每个隐藏的神经元可以被视为一个线性分类器,用一条线来表示,如图 2-26 所示。将有两个输出,一个来自每个分类器(即,隐藏神经元)。但是我们要构建一个单一的分类器,用一个输出表示类标签,而不是两个分类器。结果,两个隐藏神经元的输出将被合并成单个输出。换句话说,这两条线将由另一个神经元连接。结果如图 2-27 所示。
图 2-27
用一个隐藏的神经元把两条线连接起来
幸运的是,我们不需要添加另一个具有单个神经元的隐藏层来完成这项工作。输出层神经元将完成这项任务。这个神经元将合并先前生成的两条线,以便网络只有一个输出。
学习了隐含层及其神经元的数目后,网络架构现在就完成了,如图 2-28 所示。
图 2-28
分类问题的网络结构,其中曲线是通过连接两条线创建的,每条线都是使用隐藏层神经元创建的
示例 2:具有单一隐藏层的人工神经网络
另一个分类示例如图 2-29 所示。它类似于前面的例子,其中有两个类,每个样本有两个输入和一个输出。区别在于决策边界。本例中的边界比上例中的边界更复杂。
图 2-29
寻找最佳网络架构的更复杂的分类问题
根据指导方针,第一步是划定决策边界。我们讨论中使用的决策边界如图 2-30(a) 所示。
下一步是将决策边界分割成一组线;每条线将被模拟为人工神经网络中的感知器。在画线之前,边界改变方向的点要做好标记,如图 2-30(b) 所示。
图 2-30
对第二个示例进行分类的决策边界
问题是需要多少行。每个顶点和底点将有两条线与之相关联,总共四条线。中间点的两条线将与其他点共享。要创建的线如图 2-31 所示。
图 2-31
创建第二个示例的决策边界所需的行
因为第一隐藏层将具有与行数相等的隐藏层神经元,所以第一隐藏层将具有四个神经元。换句话说,有四个分类器,每个都由一个单层感知器创建。目前,网络将产生四个输出,每个分类器一个。下一步是将这些分类器连接在一起,以使网络只生成一个输出。换句话说,这些线将通过其他隐藏层连接在一起,以生成一条曲线。
由模型设计者来选择网络的布局。一种可行的网络架构是建立具有两个隐藏神经元的第二隐藏层。第一个隐藏神经元将连接前两条线,最后一个隐藏神经元将连接后两条线。第二次隐藏层的结果如图 2-32 所示。
图 2-32
连线以创建单个决策边界
到目前为止,已经有两条独立的曲线。因此,网络有两个输出。下一步是将这些曲线连接在一起,以便整个网络只有一个输出。在这种情况下,输出层神经元可以用于进行最终连接,而不是添加新的隐藏层。最终结果如图 2-33 所示。
图 2-33
使用输出层连接隐藏层的输出
网络设计现已完成,完整的网络架构如图 2-34 所示。
图 2-34
对第二个例子进行分类的网络架构*****************************************
三、将具有工程特征的人工神经网络用于识别
一个成功的 ML 应用的三个支柱是数据、特性和模型。他们应该互相应付。使用区分数据中存在的不同情况的最相关的特征。代表性特征对于构建精确的 ML 应用至关重要。它们应该足够精确,以便在不同条件下工作良好,例如缩放和旋转的变化。这些特性应该能够很好地与所选的 ML 模型一起工作。您不应该使用不必要的功能,因为这会增加模型的复杂性。特征选择和简化技术用于找到最小的特征集以建立精确的模型。
本章探讨了第二章中介绍的功能类别,以找到适用于 Fruits 360 数据集的手工设计功能集。应用特征缩减来最小化特征向量长度,并且仅使用最相关的特征。实现人工神经网络以将图像特征映射到它们的输出标签。在本章结束时,我们将认识到手动寻找复杂问题的特征是多么复杂,即使在同一个类中,样本之间也有多种变化。
水果 360 数据集特征挖掘
Fruits 360 数据集用于寻找一组合适的特征来训练人工神经网络,以实现高分类性能。这是一个高质量的图像数据集,收集自 60 种水果,包括苹果、番石榴、鳄梨、香蕉、樱桃、枣、猕猴桃、桃子等。平均而言,每个水果有大约 491 个训练图像和 162 个测试图像,总共有 28,736 个用于训练,9,673 个用于测试。每幅图像的大小为 100×100 像素。使用所有图像大小相同的数据集可以省去调整图像大小的预处理步骤。
特征挖掘
为了在开始时使事情变得简单,只选择了四类:Braeburn 苹果,梅尔柠檬,芒果和覆盆子。基于第二章中提出的特征类别(颜色、纹理和边缘),我们需要找到最合适的特征集来区分这些类别。
基于我们对这四种水果的了解,我们知道它们有不同的颜色。苹果是红色的,柠檬是橙色的,芒果是绿色的,覆盆子是洋红色的。因此,我们首先想到的是颜色类别。
我们可以从使用每个像素作为人工神经网络的输入开始。每个图像大小为 100×100 像素。因为图像是彩色的,所以基于 RGB 颜色空间有三个现有的通道:红色、绿色和蓝色。因此,人工神经网络的总输入数为 100×100×3=30,000。基于这些输入,将创建一个人工神经网络。
此外,这些输入将使人工神经网络变得庞大,具有大量的参数。该网络将有 30,000 个输入和 4 个输出。假设单个隐层有 1 万个神经元,那么网络的参数总数是 30000×10000+10000×4,也就是 3 亿多个参数。优化这样一个网络是复杂的。我们应该找到一种方法来减少输入特征的数量,以便减少参数的数量。
一种方法是使用单个通道,而不是使用所有三个 RGB 通道。所选通道应该能够捕捉所用类别之间的颜色变化。图 3-1 中提供了每幅图像的三个通道及其直方图。直方图比看图像更容易帮助我们可视化亮度值。
图 3-1
红色、绿色和蓝色通道,以及来自所使用的水果 360 数据集的四个类别的单个样本的直方图
清单 3-1 中提供了用于读取图像以及创建和可视化直方图的 Python 代码。
import numpy
import skimage.io
import matplotlib.pyplot
raspberry = skimage.io.imread(fname="raspberry.jpg", as_grey=False)
apple = skimage.io.imread(fname="apple.jpg", as_grey=False)
mango = skimage.io.imread(fname="mango.jpg", as_grey=False)
lemon = skimage.io.imread(fname="lemon.jpg", as_grey=False)
fruits_data = [apple, raspberry, mango, lemon]
fruits = ["apple", "raspberry", "mango", "lemon"]
idx = 0
for fruit_data in fruits_data:
fruit = fruits[idx]
for ch_num in range(3):
hist = numpy.histogram(a=fruit_data[:, :, ch_num], bins=256)
matplotlib.pyplot.bar(left=numpy.arange(256), height=hist[0])
matplotlib.pyplot.savefig(fruit+"-histogram-channel-"+str(ch_num)+".jpg", bbox_inches="tight")
matplotlib.pyplot.close("all")
idx = idx + 1
Listing 3-1RGB Channel Histogram
似乎很难找到最好的渠道来使用。根据任何通道的直方图,在图像的某些区域存在重叠。在这种情况下,区分不同图像的唯一度量是强度值。例如,Braeburn apple 和 Meyer lemon 根据蓝色通道直方图具有所有箱的值,但是它们的值不同。苹果和最右边的柠檬相比价值很小。根据光照的变化,强度值会发生变化,我们可能会遇到苹果和柠檬在直方图中的值彼此接近的情况。我们应该在不同的类别之间增加一个界限。即使只有很小的变化,在做决定时也不会有任何含糊。
我们可以从使用的四种水果具有不同的颜色这一事实中受益。将照明通道与颜色通道分离的颜色空间是一个很好的选择。图 3-2 显示了之前使用的四个样本的 HSV 色彩空间的色调通道及其直方图。
图 3-2
来自 HSV 颜色空间的色调通道及其直方图
用于返回所有样本的色调通道直方图的 Python 代码在清单 3-2 中。
import numpy
import skimage.io, skimage.color
import matplotlib.pyplot
raspberry = skimage.io.imread(fname="raspberry.jpg", as_grey=False)
apple = skimage.io.imread(fname="apple.jpg", as_grey=False)
mango = skimage.io.imread(fname="mango.jpg", as_grey=False)
lemon = skimage.io.imread(fname="lemon.jpg", as_grey=False)
apple_hsv = skimage.color.rgb2hsv(rgb=apple)
mango_hsv = skimage.color.rgb2hsv(rgb=mango)
raspberry_hsv = skimage.color.rgb2hsv(rgb=raspberry)
lemon_hsv = skimage.color.rgb2hsv(rgb=lemon)
fruits = ["apple", "raspberry", "mango", "lemon"]
hsv_fruits_data = [apple_hsv, raspberry_hsv, mango_hsv, lemon_hsv]
idx = 0
for hsv_fruit_data in hsv_fruits_data:
fruit = fruits[idx]
hist = numpy.histogram(a=hsv_fruit_data[:, :, 0], bins=360)
matplotlib.pyplot.bar(left=numpy.arange(360), height=hist[0])
matplotlib.pyplot.savefig(fruit+"-hue-histogram.jpg", bbox_inches="tight")
matplotlib.pyplot.close("all")
idx = idx + 1
Listing 3-2
Hue Channel Histograms
使用色调通道的 360 格直方图,似乎每种不同类型的水果都在直方图内投票选择特定的格。与使用任何 RGB 通道相比,不同类别之间几乎没有重叠。例如,苹果直方图中最高的条从 0 到 10,而芒果直方图的条从 90 到 110。每个类别之间的余量使得更容易减少分类中的模糊性,从而提高预测精度。
基于前面对选取的四类进行的简单实验,色调通道直方图可以正确的对数据进行分类。在这种情况下,功能的数量只有 360,而不是 30,000。这非常有助于减少人工神经网络参数的数量。
一个 360 个元素的特征向量相对于前一个很小,但是我们也可以最小化它。然而,特征向量中的一些元素可能没有足够的代表性来区分不同的类别。它们可能会降低分类模型的准确性。因此,最好删除它们以保留最好的功能集。
这不是结局。如果我们要添加更多的类,那么色调通道直方图是否足以进行准确的分类?让我们看看在使用额外的两种水果(草莓和柑橘)后事情是如何运作的。
根据我们对这两种水果的了解,草莓是红色的,类似于苹果,而柑橘是橙色的,类似于梅尔柠檬。图 3-3 显示了从这些类别中选择的样本的色调通道及其直方图。
图 3-3
来自新的两个类的样本与以前使用的样本有一些相似之处
草莓和苹果的直方图是相似的,因为它们共享从 1 到 10 的相同区间。此外,柑橘直方图和柠檬直方图相似。如何区分颜色相同的不同类别?答案是寻找另一种类型的特征。
颜色相似的水果可能有不同的质地。使用纹理描述符,如 GLCM 或 LBP,我们可以捕捉这些差异。重复前面的过程,直到选择出能够尽可能提高分类精度的最佳特征集。
LBP 产生一个大小等于输入图像大小的矩阵。为了避免增加特征向量长度,基于 LBP 矩阵创建了一个 10-bin 直方图,如图 3-4 所示。bin 值似乎存在差异。
图 3-4
苹果和草莓的 LBP 直方图
清单 3-3 列出了生成 LBP 直方图的 Python 代码。
import numpy
import skimage.io, skimage.color, skimage.feature
import matplotlib.pyplot
apple = skimage.io.imread(fname="apple.jpg", as_grey=True)
strawberry = skimage.io.imread(fname="strawberry.jpg", as_grey=True)
fig, ax = matplotlib.pyplot.subplots(nrows=1, ncols=2)
apple_lbp = skimage.feature.local_binary_pattern(image=apple, P=7, R=1)
hist1 = numpy.histogram(a=apple_lbp, bins=10)
ax[0].bar(left=numpy.arange(10), height=hist1[0])
strawberry_lbp = skimage.feature.local_binary_pattern(image=strawberry, P=7, R=1)
hist = numpy.histogram(a=strawberry_lbp, bins=10)
ax[1].bar(left=numpy.arange(10), height=hist[0])
Listing 3-3LBP Histogram
数据科学家必须寻找最佳类型的区分特征,当由于重叠类的数量而导致复杂性增加时,这并不容易实现。即使使用简单的高质量 Fruits 360 数据集,也存在区分不同类别的挑战。使用像 ImageNet 这样的数据集,有数千个类,同一类中的样本之间存在差异,要找到最佳特征是一项需要手动完成的复杂任务。对于有大量数据的情况,自动方法是优选的。
特征约简
这一部分将基于前四个结果处理由色调通道直方图组成的特征向量。查看图 3-2 中的直方图,很明显有太多几乎为零值的面元。这意味着它们没有被任何类使用。最好移除这些元素,因为这有助于减少特征向量长度。
根据第二章中介绍的特征减少技术,当很难知道要删除什么元素时,使用包装和嵌入类别。例如,一些元素可能在一些类中表现良好,但在其他类中表现很差。因此,我们必须删除它们。包装器和嵌入式方法依赖于用多个特征集训练的模型,以便知道哪些元素有助于提高分类准确度。在我们的例子中,我们不需要使用它们。原因是有些元素在所有的类中都是不好的,因此很明显我们应该删除什么。因此,过滤方法是一个很好的选择。
反过来,STD 也是过滤元素的好选择。好的元素是那些 STD 值高的元素。一个 sigh STD 值意味着该元素对于不同的类是有区别的。具有低 STD 值的元素在所有不同的类中具有几乎相同的值。这意味着它无法区分不同的类别。
根据等式 3-1 计算给定元素的 STD。
(方程式 3-1)
其中 X 是给定样本的元素值, X ̂是数据集中所有样本的元素平均值, n 是样本数。
在决定删除哪个元素之前,我们必须从数据集中的所有样本中提取特征向量。清单 3-4 从使用的四种水果的每个样本中提取特征向量。
import numpy
import skimage.io, skimage.color, skimage.feature
import os
import pickle
fruits = ["apple", "raspberry", "mango", "lemon"]
#492+490+490+490=1,962
dataset_features = numpy.zeros(shape=(1962, 360))
outputs = numpy.zeros(shape=(1962))
idx = 0
class_label = 0
for fruit_dir in fruits:
curr_dir = os.path.join(os.path.sep,'train', fruit_dir)
all_imgs = os.listdir(os.getcwd()+curr_dir)
for img_file in all_imgs:
fruit_data = skimage.io.imread(fname=os.getcwd()+curr_dir+img_file, as_grey=False)
fruit_data_hsv = skimage.color.rgb2hsv(rgb=fruit_data)
hist = numpy.histogram(a=fruit_data_hsv[:, :, 0], bins=360)
dataset_features[idx, :] = hist[0]
outputs[idx] = class_label
idx = idx + 1
class_label = class_label + 1
with open("dataset_features.pkl", "wb") as f:
pickle.dump("dataset_features.pkl", f)
with open("outputs.pkl", "wb") as f:
pickle.dump(outputs, f)
Listing 3-4Feature Vector Extraction from All Samples
名为“dataset_features”的数组包含所有要素。它的大小为 1,962×360,其中 360 是直方图仓的数量,1,962 是样本的数量(492 个苹果+ 490 个其他三种水果)。类别标签保存在“输出”数组中,其中苹果的标签为 0,覆盆子的标签为 1,芒果的标签为 2,柠檬的标签为 3。代码结束时,将保存要素和输出标签,以便以后重用。
这段代码假设有四个文件夹,根据每个水果来命名。它遍历这些文件夹,读取所有图像,计算直方图,并将其返回到“dataset_features”变量中。之后,我们准备计算 STD。所有特征的标准偏差根据下面这条线计算:
features_STDs = numpy.std(a=dataset_features, axis=0)
这返回长度为 360 的向量,其中给定位置的元素引用该位置的特征向量的元素的 STD。360 种性传播疾病的分布如图 3-5 所示。
图 3-5
跨所有样本的特征向量的所有元素的 STDs 分布
基于这种分布,STD 的最小值、最大值和平均值分别为 0.53、549.13 和 44.22。应移除具有较小 STD 值的要素,因为它们无法区分不同的类。我们必须选择一个阈值,将特征分为坏的(低于阈值)和好的(高于阈值)。
使用人工神经网络过滤
选择阈值的一种方法是反复试验。尝试不同的阈值。通过每个阈值返回的缩减的特征向量,训练分类模型并注意准确性。使用最大化精度的简化特征向量。
清单 3-5 给出了使用 scikit-learn 库创建和训练 ANN 的 Python 代码,该库具有一组通过使用阈值生成的特征。
import sklearn.neural_network
import numpy
import pickle
with open("dataset_features.pkl", "rb") as f:
dataset_features = pickle.load(f)
with open("outputs.pkl", "rb") as f:
outputs = pickle.load(f)
threshold = 50
features_STDs = numpy.std(a=dataset_features, axis=0)
dataset_features2 = dataset_features[:, features_STDs>threshold]
ANN = sklearn.neural_network.MLPClassifier(hidden_layer_sizes=[150, 60],
activation="relu",
solver="sgd",
learning_rate="adaptive",
max_iter=300,
shuffle=True)
ANN.fit(X=dataset_features2, y=outputs)
predictions = ANN.predict(X=dataset_features2)
num_flase_predictions = numpy.where(predictions != outputs)[0]
Listing 3-5Building ANN Using scikit-learn Trained with STD Thresholded Features
加载要素和输出,以便计算它们的 STD 并根据预定义的阈值过滤要素。一个多层感知器分类器由两个隐层构成,其中第一个隐层有 150 个神经元,第二个隐层有 60 个神经元。该分类器的一些特性被指定:激活函数被设置为校正线性单元(ReLU)函数,随机梯度下降(GD)是学习算法,学习率由学习者自动选择,有 300 个最大迭代来训练网络,最后网络被设置为真,以便在每次迭代中选择不同的训练样本。
阈值为 50 时,其余特征的分布如图 3-6 所示。所有低质量的元素都被去除,从而使用最佳的元素集。这减少了用于训练网络的数据量;因此,训练速度更快。它还防止坏的特征元素降低精度。当使用特征向量中的所有元素时,有 490 个错误预测。在阈值化之后,特征元素使用 STD 阈值 50,错误预测的数量下降到零。
图 3-6
移除 STD 低于 50 的元素后的 STD 分布
分类错误的减少不是唯一的好处;人工神经网络参数也有所减少。在只使用了 STD 大于 50 的特性元素之后,剩余的元素数量只有 102。根据清单 3-5 中的 ANN 结构,与使用长度为 360 的完整特征向量时的 54,000 个参数相比,输入层和第一隐藏层中的参数数量将为 102×150= 15,300。减少了 38,700 个参数。
人工神经网络实现
本节用 Python 实现了一个 ANN。人工神经网络根据每层(输入、隐藏和输出)中神经元的数量来接受网络结构,然后它通过多次迭代来训练网络。为了熟悉实施步骤,图 3-7 将人工神经网络结构可视化。有一个输入层有 102 个输入,两个隐藏层有 150 和 60 个神经元,一个输出层有 4 个输出(每个水果类一个)。
图 3-7
要实现的人工神经网络的体系结构
任一层的输入向量乘以(矩阵乘法)连接到下一层的权重矩阵,以产生输出向量。输出向量再次乘以连接其层和下一层的权重矩阵。该过程一直持续到到达输出层。矩阵乘法的总结如图 3-8 所示。
图 3-8
输入和权重之间的矩阵乘法
大小为 1×102 的输入向量将乘以大小为 102×150 的第一个隐藏层的权重矩阵。这就是矩阵乘法。因此,输出大小为 1×150。然后,该输出被用作第二个隐藏层的输入,在第二个隐藏层中,该输出被乘以大小为 150×60 的权重矩阵。结果大小为 1×60。最后,输出乘以第二个隐藏层和大小为 60×4 的输出层之间的权重。结果的最终大小为 1×4。结果向量中的每个元素都引用一个输出类。根据具有最高分数的类来标记输入样本。
实现这种乘法的 Python 代码在清单 3-6 中。
import numpy
import pickle
def sigmoid(inpt):
return 1.0/(1+numpy.exp(-1*inpt))
f = open("dataset_features.pkl", "rb")
data_inputs2 = pickle.load(f)
f.close()
features_STDs = numpy.std(a=data_inputs2, axis=0)
data_inputs = data_inputs2[:, features_STDs>50]
f = open("outputs.pkl", "rb")
data_outputs = pickle.load(f)
f.close()
HL1_neurons = 150
input_HL1_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(data_inputs.shape[1], HL1_neurons))
HL2_neurons = 60
HL1_HL2_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(HL1_neurons, HL2_neurons))
output_neurons = 4
HL2_output_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(HL2_neurons, output_neurons))
H1_outputs = numpy.matmul(a=data_inputs[0, :], b=input_HL1_weights)
H1_outputs = sigmoid(H1_outputs)
H2_outputs = numpy.matmul(a=H1_outputs, b=HL1_HL2_weights)
H2_outputs = sigmoid(H2_outputs)
out_outputs = numpy.matmul(a=H2_outputs, b=HL2_output_weights)
predicted_label = numpy.where(out_outputs == numpy.max(out_outputs))[0][0]
print("Predicted class : ", predicted_label)
Listing 3-6ANN Matrix Multiplications
在读取之前保存的要素及其输出标注并使用等于 50 的标准差阈值过滤要素后,定义图层的权重矩阵。它们被随机赋予从-0.1 到 0.1 的值。例如,变量“input_HL1_weights”保存输入层和第一个隐藏层之间的权重矩阵。该矩阵的大小根据特征元素的数量和隐藏层中神经元的数量来定义。
创建权重矩阵后,下一步是应用矩阵乘法。例如,变量“H1 输出”保存将给定样本的特征向量乘以输入层和第一隐藏层之间的权重矩阵的输出。
通常,激活函数被应用于每个隐藏层的输出,以在输入和输出之间创建非线性关系。例如,矩阵乘法的输出被应用于等式 3-2 中的 sigmoid 激活函数。
(方程式 3-2)
生成输出层输出后,进行预测。预测的类标签被保存到“预测 _ 标签”变量中。
对每个输入样本重复这些步骤。适用于所有样本的完整代码在清单 3-7 中。
import numpy
import pickle
def sigmoid(inpt):
return 1.0/(1+numpy.exp(-1*inpt))
def relu(inpt):
result = inpt
result[inpt<0] = 0
return result
def update_weights(weights, learning_rate):
new_weights = weights - learning_rate*weights
return new_weights
def train_network(num_iterations, weights, data_inputs, data_outputs, learning_rate, activation="relu"):
for iteration in range(num_iterations):
print("Itreation ", iteration)
for sample_idx in range(data_inputs.shape[0]):
r1 = data_inputs[sample_idx, :]
for idx in range(len(weights)-1):
curr_weights = weights[idx]
r1 = numpy.matmul(a=r1, b=curr_weights)
if activation == "relu":
r1 = relu(r1)
elif activation == "sigmoid":
r1 = sigmoid(r1)
curr_weights = weights[-1]
r1 = numpy.matmul(a=r1, b=curr_weights)
predicted_label = numpy.where(r1 == numpy.max(r1))[0][0]
desired_label = data_outputs[sample_idx]
if predicted_label != desired_label:
weights = update_weights(weights,
learning_rate=0.001)
return weights
def predict_outputs(weights, data_inputs, activation="relu"):
predictions = numpy.zeros(shape=(data_inputs.shape[0]))
for sample_idx in range(data_inputs.shape[0]):
r1 = data_inputs[sample_idx, :]
for curr_weights in weights:
r1 = numpy.matmul(a=r1, b=curr_weights)
if activation == "relu":
r1 = relu(r1)
elif activation == "sigmoid":
r1 = sigmoid(r1)
predicted_label = numpy.where(r1 == numpy.max(r1))[0][0]
predictions[sample_idx] = predicted_label
return predictions
f = open("dataset_features.pkl", "rb")
data_inputs2 = pickle.load(f)
f.close()
features_STDs = numpy.std(a=data_inputs2, axis=0)
data_inputs = data_inputs2[:, features_STDs>50]
f = open("outputs.pkl", "rb")
data_outputs = pickle.load(f)
f.close()
HL1_neurons = 150
input_HL1_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(data_inputs.shape[1], HL1_neurons))
HL2_neurons = 60
HL1_HL2_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(HL1_neurons, HL2_neurons))
output_neurons = 4
HL2_output_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(HL2_neurons, output_neurons))
weights = numpy.array([input_HL1_weights,
HL1_HL2_weights,
HL2_output_weights])
weights = train_network(num_iterations=2,
weights=weights,
data_inputs=data_inputs,
data_outputs=data_outputs,
learning_rate=0.01,
activation="relu")
predictions = predict_outputs(weights, data_inputs)
num_flase = numpy.where(predictions != data_outputs)[0]
print("num_flase ", num_flase.size)
Listing 3-7Complete Code for ANN
“权重”变量包含整个网络的所有权重。基于每个权重矩阵的大小,网络结构被动态地指定。例如,如果“input_HL1_weights”变量的大小是 102×80,那么我们可以推断第一个隐藏层有 80 个神经元。
“train_network”是核心功能,因为它通过循环所有样本来训练网络。对于每个样品,应用清单 3-6 中讨论的步骤。它接受训练迭代次数、特征、输出标签、权重、学习率和激活函数。激活功能有两个选项:ReLU 或 sigmoid。ReLU 是一个阈值函数,只要它大于零,就返回相同的输入。否则,它返回零。
如果网络对给定的样本做出了错误的预测,则使用“更新权重”函数来更新权重。不使用优化算法来更新权重;它们只是根据学习率进行更新。准确率不超过 45%。下一章将讨论如何使用 GA 优化技术来完成这项任务,这将提高分类的准确性。
在指定次数的训练迭代之后,根据训练数据测试网络,以查看网络是否在训练样本上工作良好。如果基于训练数据的准确度是可接受的,那么我们可以基于新的看不见的数据来测试模型。
工程功能限制
Fruits 360 数据集图像是在一个受限的环境中捕获的,其中包含每个水果的许多可用细节。这使得挖掘数据以找到最佳特性变得更加容易。不幸的是,现实世界的应用并不那么容易。同一类中的样本之间存在许多差异,例如不同的视角、透视变形、光照变化、遮挡等等。为这些数据创建特征向量是一项复杂的任务。
图 3-9 给出了来自 MNIST(改进的国家标准与技术研究所)数据集的一些手写数字识别样本。它由 70,000 个样本组成。图像是二进制的,因此颜色特征类别不适用。再看另一个特性,似乎没有一个特性能够适用于整个数据集。因此,我们必须使用多个特征来覆盖数据集中存在的所有变化。这肯定会产生一个巨大的特征向量。
图 3-9
来自 CIFAR10(加拿大高级研究所)数据集的样本
假设我们能够找到一个好的特征,还有另一个问题。单层人工神经网络导致 12.0%的错误率。因此,我们可以增加人工神经网络的深度。不幸的是,深度人工神经网络架构使用的大特征向量计算起来非常麻烦,但这是处理复杂问题的方法。
另一种方法是避免手动特征挖掘方法。开始寻找一个自动的特征挖掘,在最大化准确性方面搜索最佳的特征集。
工程特征的终结
工程特性不是遗留的,仍然可以解决一些问题。当处理一些复杂的数据集时,这不是一个好的选择。
每个数据科学家都会使用计算器进行数学计算。在移动电话的发明和发展之后,智能手机出现了,它具有不同的应用来执行以前在计算器上完成的操作。这里有一个问题:一项新技术(智能手机)的出现是否意味着以前的技术(计算器)被摧毁,不再被使用?
计算器只用于数学运算,但智能手机不是。智能手机有许多计算器没有的功能。许多功能的可用性而不是有限的功能是一个缺点吗?在某些情况下,工具中的功能越少,其性能就越好;而且,功能越多,开销越大。使用计算器进行运算很简单,但使用智能手机进行同样的运算会产生开销。
电话可能会响起来一个来电,打断你正在做的事情。它可能连接到互联网,因此也可能会发出电子邮件的嘟嘟声。这可能会让你不去做手术。因此,使用智能手机的人应该关心所有这些影响,以便很好地进行数学运算。与智能手机相比,使用功能有限的计算器具有简单和专注于任务的优势,即使这是一项古老的技术。事实上,最新的并不总是最好的。根据您的需求,旧技术可能比新技术更好,也可能更差。从数据科学的角度来看,情况也是如此。
有不同类型的学习算法和特征用于不同的任务,例如分类和回归。其中一些可以追溯到 1950 年,而另一些是最近的。但也不能说老机型总是比最近的差。我们不能绝对的断定 CNN 等 DL 模型比之前的模型更好。这个看你的需求了。
许多研究人员倾向于盲目使用 DL,因为它是最先进的方法。有些问题很简单,使用 DL 可能会增加更多的复杂性。例如,将 DL 与 100 个图像分成 10 个类一起使用并不是一个好的选择。浅层学习在这种情况下就足够了。如果要创建一个分类器来区分之前使用的四种水果,DL 不是强制性的,之前的手工/工程特征就足够了。
如果在这种情况下使用 CNN,会增加一些开销,使任务变得复杂。需要指定不同的参数,例如层的类型、层数、激活函数、学习速率等。相比之下,使用色调通道直方图足以实现非常高的准确度。这就像用梯子爬到墙头一样。如果你在爬了五级楼梯后到达了墙的顶部,你就不需要再爬一级楼梯了。同样,如果您可以使用手工设计的特征获得最佳结果,则不必使用自动特征学习。
四、人工神经网络优化
在自动特征学习方法创新之前,数据科学家被要求知道使用什么特征、使用哪个模型、如何优化结果等等。随着大量数据和高速设备的存在,DL 可用于自动推断最佳特征。数据科学家的两个核心任务是模型设计和优化。
模型优化与构建模型本身一样重要,甚至更重要。先前创建的证明其准确性的 DL 模型可以被重用,从而解决了模型设计问题。剩下的任务是优化。我们正处于优化的时代,运筹学(OR)科学家在其中扮演着至关重要的角色。最优化领域与人工智能密切相关。
为 ML 任务选择最佳参数具有挑战性。一些结果可能不是因为数据有噪声或者所使用的学习算法弱,而是因为参数值的错误选择。理想情况下,优化通过查看不同的解决方案并选择最佳方案来保证返回最佳方案。定义解决方案优劣的指标越多,就越难找到最佳解决方案。本章介绍了最优化,并讨论了一种简单的最优化技术,称为遗传算法。基于给出的例子,如何在基于优势概念的单目标和多目标优化问题(MOOPs)中使用它将变得清楚。该算法与人工神经网络一起使用,以产生更好的权重,有助于提高分类精度。
优化简介
假设一位数据科学家将一个图像数据集划分为多个类别,并且要创建一个图像分类器。在数据科学家研究了数据集之后,K-最近邻(KNN)似乎是一个不错的选择。要使用 KNN 算法,有一个重要参数需要使用,即 K,指的是邻居的数量。假设选择初始值 3。
科学家从选定的 K=3 开始 KNN 算法的学习过程。训练后的模型达到了 85%的分类准确率。这个百分比可以接受吗?换句话说,我们能得到比目前更好的分类精度吗?在进行不同的实验之前,我们不能说 85%是最好的准确度。但是要做另一个实验,我们肯定必须在实验中改变一些东西,比如改变 KNN 算法中使用的 K 值。我们不能肯定地说 3 是在这个实验中使用的最佳值,除非我们尝试不同的 K 值并注意分类准确度如何变化。问题是如何找到使分类性能最大化的 K 的最佳值。这被称为超参数优化。
在最优化中,我们从实验中使用的变量的某种初始值开始。因为这些值可能不是最佳值,所以我们必须改变它们,直到得到最佳值。在某些情况下,这些值是由复杂的函数生成的,我们无法轻松地手动求解。但是进行优化是非常重要的,因为分类器可能产生差的分类精度。原因可能不是数据有噪声或者使用的学习算法弱,而是参数选择不好。因此,或研究人员提出了不同的优化技术来完成此类工作。
单目标与多目标优化
对最优化问题进行分类的一种方法是基于它是单目标问题还是多目标问题。让我们在这一小节中对它们进行区分。
假设有一个图书出版商想从卖书中获取最大利润。他们用等式 4-1 来计算他们每天的利润,其中 X 代表书籍的数量,Y 代表利润。优化时要问自己的问题是,为了让结果更好,要改变什么。
Y=(X2)3+3(方程式 4-1)
我们可以回到前面的问题。为了优化前面的问题,我们希望达到输出变量的最佳值。这里,我们只有一个输出变量,y。
为了得到输出变量 Y 的最佳值,我们可以在问题中改变什么来改变变量 Y?换句话说,Y 依赖的变量是什么?看看等式 4-1,Y 只依赖于一个变量,即输入变量 X。通过改变 X,我们可以将 Y 改变为一个更好的值。因此,前一个问题可以适用于这个特定的问题,如下所示:输入变量 X 的最佳值是什么,它返回输出变量 Y 的最佳值?
假设输入变量 X 的范围是 1 到 3,包括 1 和 3。哪个值的利润最高?如果没有信息指引我们找到最佳解决方案,我们必须尝试所有可能的解决方案(即输入变量 X 的所有可能值),并选择利润最大化的解决方案(即对应于输出变量 Y 的最大值的解决方案)。表 4-1 显示了所有可能的 X 值及其对应的 Y 值。基于它,最佳解是 Y=4,对应 X=1。
表 4-1
单变量问题的所有可能的解决方案
|X
|
Y
|
| --- | --- |
| one | four |
| Two | three |
| three | Two |
让我们把问题变得复杂一点。假设这个问题有另一个用于利润计算的因素,那就是它的在线网站的访问者数量。它被表示为变量 Z,取值范围从 1 到 2。修正在方程 4-2 中。按照前面的程序,我们需要尝试表 4-2 中输入 X 和 Z 的所有可能组合。最佳解对应于 X=2 和 Z=2。
Y=Z3-(X2)3+3(方程式 4-2)
表 4-2
有两个输入变量的问题的所有可能的解
|X
|
Z
|
Y
|
| --- | --- | --- |
| one | one | five |
| one | Two | six |
| Two | one | four |
| Two | Two | Eleven |
| three | one | three |
| three | Two | Ten |
有时输入变量的范围是无限的,我们不能尝试它的所有值。例如,输入 X 和 Z 的范围可能都是实数。按照前面尝试所有可能值的过程,我们将在这种情况下失败。必须有某种东西引导我们走向最佳解决方案,而不需要尝试所有可能的输入值。
以前的优化问题只有一个目标,就是利润最大化。另一个目标可以是最小化由等式 4-3 表示的废纸,其中 W 表示废纸的量,范围从 2 到 4 吨。结果,问题变成了 MOOP,如方程 4-4 所示。
K=(X2)2+1(方程式 4-3)
| 在哪里***Y***=***Z***3—***X***—2)2+3***K***=(***X***2)2+1 使遭受 1≤??**≤3&【1≤**【z】**≤2** | (方程式 4-4) |我们的目标不仅是利润最大化,而且是废纸量最小化。这使得问题变得更加复杂,因为我们必须记住,X 的选定值应该满足两个目标,而不是一个,特别是当两个目标相互冲突时。这是因为减少废纸量可能会降低利润。目标之间必须有所取舍,因为一个解决方案在一个目标中可能更好,而在另一个目标中可能更差。注意,为了简单起见,最大化目标被转化为最小化目标。
随着目标和变量数量的增加,复杂性也随之增加,问题变得难以人工解决。这就是为什么我们需要自动优化技术来解决这样的问题。
本章讨论遗传算法,这是一个简单的技术,解决单目标和多目标优化问题。非支配排序遗传算法-II (NSGA-II)是一种基于遗传算法的多目标进化算法(MOEA ),寻找满足多个目标的可行解。因为 MOOPs 可能有多个解决方案,NSGA-II 可以返回所有目标的可能可行的解决方案。基于用户的偏好,可以筛选出最佳的单个解决方案。
观察各种自然物种,我们可以注意到它们是如何进化和适应环境的。我们可以从这些已经存在的自然系统和它们的自然进化中受益,来创造我们的人工系统做同样的工作。这叫仿生学。比如飞机是基于鸟类如何飞行,雷达来自蝙蝠,潜艇是基于鱼类发明的等等。因此,一些优化算法的原理来自于自然。比如 GA,它的核心思想来自查尔斯·达尔文的自然进化论:“适者生存。”
我们可以说优化是使用 EAs 执行的。传统算法和进化算法的区别在于进化算法不是静态的,而是动态的,因为它们可以随着时间的推移而进化。
职业介绍所有三个主要特征:
-
基于群体:进化算法是优化一个过程,在这个过程中,当前的解决方案是不好的,以产生新的和更好的解决方案。从中产生新解的当前解的集合称为群体。
-
以健身为导向:如果有几个方案,怎么能说一个方案比另一个方案好呢?从适应度函数计算出的每个单独的解决方案都有一个相关的适应度值。这样的适应值反映了解决方案有多好。
-
变异驱动:如果根据从每个个体计算的适应度函数,在当前种群中没有可接受的解,我们应该做出一些东西来生成新的更好的解。因此,单个解决方案将经历许多变化以生成新的解决方案。
我们现在将开始讨论应用这些概念的遗传算法。
通用航空
遗传算法是一种随机优化技术。所谓“随机”,是指为了使用 GA 找到一个解决方案,随机变化被应用到当前的解决方案以生成新的解决方案。遗传算法是基于达尔文的进化论。这是一个缓慢、渐进的过程,通过对其解决方案进行细微的改变,直到找到更好的解决方案。通过在几代人之间发展解决方案,新的解决方案有望比旧的解决方案更好。
遗传算法处理由多个解组成的群体。群体大小是解的数量。每个解决方案都被称为个体。每个个体代表一条染色体。染色体被表示为一组定义个体特征或参数的基因。有不同的方式来表示基因,如二进制或十进制。图 4-1 给出了一个有四个个体(染色体)的群体的例子,其中每个染色体有四个基因,每个基因用一个二进制数字表示。
图 4-1
GA 的群体、染色体和基因
在建立了第一代(第 0 代)的种群后,下一步是选择最佳的交配方案并产生新的更好的方案。为了选择最佳解决方案,使用了适应度函数。适应度函数的结果是代表解的质量的适应度值。适应值越高,解的质量越高。在配对池中选择具有最高适应值的解决方案。这样的解决方案将结合产生新的解决方案。
交配池内的解称为亲本。父母为了产生后代(孩子)而交配。仅仅通过优质个体的交配,就有望获得比其父母更优质的后代。这阻止了坏个体产生更多的坏个体。保持选择和匹配高质量的个体,通过只保留好的特性和去除坏的特性,有更高的机会提高解决方案的质量。最后,这将以期望的最优或可接受的解决方案结束。
当双亲简单交配时,后代只具有双亲的特征;没有添加新的属性。假设所有的父母都患有一种局限性,他们一起交配肯定会产生具有相同局限性的后代。为了克服这个问题,对每个后代进行一些改变,以产生具有新特性的新个体。新的后代将是下一代人口中的解决方案。
因为应用于后代的改变是随机的,所以我们不能确定新的后代会比双亲更好。当代人的解决方案可能比他们的父辈更糟糕。因此,新的种群将由父母和后代组成。一半是父母,另一半是新的后代。如果群体大小是 8,那么新的群体将由之前的 4 个父母和 4 个后代组成。在最坏的情况下,当所有的后代都比父母更差时,质量不会下降,就像我们保留了父母一样。图 4-1 总结了 GA 的步骤。
要全面了解 GA,需要回答两个问题:
-
父母双方的两个后代是如何产生的?
-
每一个后代是如何被轻微改变的?
这些问题我们以后再来回答。
染色体有不同的表示法,选择正确的表示法要视具体问题而定。一个好的表示法是使搜索空间更小,从而更容易搜索。
可用于染色体的表示包括以下内容:
-
二进制:每个染色体被表示为一串 0 和 1。
-
排列:对于排序问题很有用,比如旅行推销员问题。
-
值:实际值按原样编码。
例如,如果我们用二进制编码数字 5,它可能看起来像图 4-2 中的第一条染色体。
图 4-2
GA 步骤
前面染色体的每一部分被称为一个基因。每个基因都有两种特性。第一个是它的值(等位基因),第二个是在染色体内的位置(基因座)。图 4-1 中每条染色体最右边的位置代表位置 0,最左边的位置代表位置 3。
每条染色体有两种表现形式:
-
基因型:代表染色体的一组基因。
-
表型:染色体的实际物理表现。
二进制数 0101 2 为基因型,5 10 为表现型表示。二进制表示可能不是表示给定问题解决方案的最佳方式,尤其是当表示基因的位数不固定时。
以正确的方式表示每个染色体后,下一步是计算每个个体的适应值。
最佳父母选择
假设等式 4-5 是我们在图 4-1 的例子中使用的适应度函数,其中 x 是染色体十进制值。
f(x)= 2x2(等式 4-5)
第一个解决方案的适应度值为十进制值 5,计算如下:
计算染色体适应值的过程称为评估。表 4-3 中给出了所有解决方案的适应值。
表 4-3
每个解决方案的适合度值
|解决方案编号
|
小数值
|
健身价值
|
| --- | --- | --- |
| one | five | eight |
| Two | Eleven | Twenty |
| three | Twelve | Twenty-two |
| four | Two | Two |
在交配池中选择当前种群中最好的个体。在这一步之后,我们将最终选择交配池中的一个种群子集。但是父母选择的数量是多少呢?这取决于正在解决的问题。在我们的例子中,我们可以只选择两个父母。这两个父母将交配产生两个后代。父母和后代的结合将产生一个由四个父母组成的新群体。根据表 4-3 ,最好的两个方案是编号为 2 和 3 的方案。
变异算子
选择的两个亲本应用于变异算子以产生后代。算子是交叉和变异。
交叉
使用交叉操作,来自父母双方的基因被选择来创建新的孩子。因此,孩子将继承父母双方的财产。每个父母携带的基因数量是不固定的。有时后代从父母一方获得一半基因,从另一方获得另一半基因,有时这些百分比会发生变化。
对于每两个亲本,通过选择染色体中的随机点并交换来自两个亲本的该点前后的基因来进行交叉。产生的染色体就是后代。因为我们用了单点来分裂染色体,所以这个算子叫做单点交叉。有不同类型的操作符,如混合、两点和均匀。图 4-3 显示了如何在两个亲本之间应用交叉来产生两个后代。
图 4-3
双亲之间的单点杂交产生两个后代
变化
基于交叉操作,除了双亲中存在的特性之外,没有新的特性添加到基因中。这是因为所有的基因都取自父母。通过从每个染色体中选择一定百分比的基因并随机改变它们的值来应用突变。突变因染色体表现而异。如果使用二进制编码(即每个基因的值空间正好是 0 和 1),那么翻转参与变异操作的每个基因的位值。其他类型的突变包括交换、反向、均匀、非均匀、高斯和收缩。
应用突变的基因的百分比应该很小,因为变化是随机的。我们不应该承担由于随机变化而丢失大量现有信息的风险,因为随机变化不能保证更好的结果。对于我们的问题,我们可以只选择一个基因来随机翻转它的值。图 4-4 显示了选择位置 0 最左边的基因进行突变时的结果。请注意,变异应用于交叉结果。
图 4-4
交叉结果上的位翻转变异
通过应用交叉和变异,新的后代完全准备好了。我们可以根据适应值来衡量他们比父母更好还是更差。两个后代的适应值对于第一个后代是 16,对于第二个后代是 26。与双亲(20 和 22)的适应值相比,第二个后代中的一个比所有双亲更好,并且 GA 能够进化解决方案以产生更好的一个。但是第一个适应值为 16 的后代比所有的父母都差。保持在新群体中选择的亲本确保了这样的坏解在下一代中不会被选择作为亲本。因此,我们确信下一代解决方案的质量不会比上一代差。
在一些问题中,基因不是用二进制表示的,因此突变是不同的。如果基因值来自多于两个值的空间,例如(1,2,3,4,5),则位翻转突变不适用。一种方法是从这个集合中随机选择一个值。图 4-5 给出了一个由基因的有限值(多于两个值)表示的解决方案的例子。被选择用于突变的基因的值被随机地改变为其它值之一。
图 4-5
基因有两个以上值的解的一致变异
有时解决方案由一组无限的值来表示。例如,如果值的范围在–1.0 和 1.0 之间,我们可以选择该范围内的任何值来替换旧值。
Python 实现的一个例子
现在我们已经了解了遗传算法的概念,让我们用 Python 来实现它,以优化一个简单的例子,在这个例子中,我们将最大化等式 4-6 的输出。这就是健身功能。实现中使用了十进制表示、单点交叉和均匀变异。
和=【w】**+【w】2
该方程有六个输入( x 1 到x6)和六个权重( w _ 1 到 w 6 ),输入值为( x 1 , x 2 ,x3我们正在寻找使这个等式最大化的参数(权重)。最大化这个等式的想法似乎很简单。正输入要乘以最大可能的正数,负数要乘以最小可能的负数。但我们希望实现的想法是如何让 GA 自己完成这项工作。GA 自己应该知道,最好是正输入用正权重,负输入用负权重。让我们开始实现 GA。
根据清单 4-1 ,创建了一个包含六个输入的列表,以及一个包含权重数量的变量。
# Inputs of the equation.
equation_inputs = [4,-2,3.5,5,-11,-4.7]
# Number of the weights we are looking to optimize.
num_weights = 6
Listing 4-1Inputs of the Function to Optimize
下一步是定义初始群体。基于权重的数量,群体中的每个染色体(解或个体)肯定会有六个基因,一个基因对应一个权重。但问题是,每个人口有多少解?没有固定的值,我们可以选择最适合我们问题的值。但是我们可以让它保持通用,这样就可以在代码中修改它。在清单 4-2 中,创建了一个变量来保存每个群体的解的数量,另一个变量保存群体的大小,最后一个变量保存实际的初始群体。
import numpy
sol_per_pop = 8
# Defining the population size.
pop_size = (sol_per_pop,num_weights) # The population will have sol_per_pop chromosome where each chromosome has num_weights genes.
#Creating the initial population.
new_population = numpy.random.uniform(low=-4.0, high=4.0, size=pop_size)
Listing 4-2Creating the Initial Population
导入 numpy 库后,我们能够使用 numpy.random.uniform 函数随机创建初始群体。根据所选参数,其形状为(8,6)。也就是说,有八条染色体,每条染色体有六个基因,每个基因对应一个体重。表 4-4 给出了运行前一个代码后群体的解。请注意,它是由代码随机生成的,因此当您运行它时,它肯定会发生变化。
表 4-4
原始群体
| |W?? 1
|
W2
|
W3
|
W4
|
W5
|
W6
|
| --- | --- | --- | --- | --- | --- | --- |
| 解决方案 1 | –2.19 | –2.89 | Two point zero two | –3.97 | Three point four five | Two point zero six |
| 解决方案 2 | Two point one three | Two point nine seven | Three point six | Three point seven nine | Zero point two nine | Three point five two |
| 解决方案 3 | One point eight one | Zero point three five | One point zero three | –0.33 | Three point five three | Two point five four |
| 解决方案 4 | –0.64 | –2.86 | Two point nine three | –1.4 | –1.2 | Zero point three one |
| 解决方案 5 | –1.49 | –1.54 | One point one two | –3.68 | One point three three | Two point eight six |
| 解决方案 6 | One point one four | Two point eight eight | One point seven five | –3.46 | Zero point nine six | Two point nine nine |
| 解决方案 7 | One point nine seven | Zero point five one | Zero point five three | –1.57 | –2.36 | Two point three |
| 解决方案 8 | Three point zero one | –2.75 | Three point two seven | –0.72 | Zero point seven five | Zero point zero one |
准备好群体后,接下来是按照图 4-2 中 GA 的步骤进行。基于适应度函数,我们将选择当前种群中最好的个体作为交配的亲本。下一步是应用 GA 变体(交叉和变异)来产生下一代的后代,通过追加父母和后代来创建新的群体,并重复这些步骤多次迭代/世代。清单 4-3 应用这些步骤。
import GA
num_generations = 10,000
num_parents_mating = 4
for generation in range(num_generations):
# Measuring the fitness of each chromosome in the population.
fitness = GA.cal_pop_fitness(equation_inputs, new_population)
# Selecting the best parents in the population for mating.
parents = GA.select_mating_pool(new_population, fitness,
num_parents_mating)
# Generating next generation using crossover.
offspring_crossover = GA.crossover(parents,
offspring_size=(pop_size[0]-parents.shape[0], num_weights))
# Adding some variations to the offspring using mutation.
offspring_mutation = GA.mutation(offspring_crossover)
# Creating the new population based on the parents and offspring.
new_population[0:parents.shape[0], :] = parents
new_population[parents.shape[0]:, :] = offspring_mutation
Listing 4-3Iterating Through GA Steps
名为“GA”的模块保存了清单 4-3 中使用的函数的实现。第一个函数名为 GA.cal_pop_fitness,用于查找群体中每个解的适应值。根据清单 4-4 ,该功能在 GA 模块内定义。
def cal_pop_fitness(equation_inputs, pop):
# Calculating the fitness value of each solution in the current population.
# The fitness function calculates the SOP between each input and its corresponding weight.
fitness = numpy.sum(pop*equation_inputs, axis=1)
return fitness
Listing 4-4GA Fitness Function
除了总体,适应度函数还接受方程输入值(x_1 到 x_6)。根据等式 4-6,将适应值计算为每个输入与其相应基因(权重)之间的 SOP。根据每个群体的解决方案数量,将有与表 4-5 中相同数量的标准操作程序。请注意,适应值越高,解决方案越好。
表 4-5
初始种群解的适应值
| |解决方案 1
|
解决方案 2
|
解决方案 3
|
解决方案 4
|
解决方案 5
|
解决方案 6
|
解决方案 7
|
解决方案 8
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 健康 | Sixty-three point four one | Fourteen point four | –42.23 | Eighteen point two four | –45.44 | –37.0 | Sixteen | Seventeen point zero seven |
在计算了所有解的适应值之后,下一步是根据 GA.select _ mating _ pool 函数在交配池中选择其中最好的作为亲本。该函数接受群体、适应值和所需的父代数量,并返回所选的父代。它在 GA 模块中的实现如清单 4-5 所示。
def select_mating_pool(pop, fitness, num_parents):
# Selecting the best individuals in the current generation as parents for producing the offspring of the next generation.
parents = numpy.empty((num_parents, pop.shape[1]))
for parent_num in range(num_parents):
max_fitness_idx = numpy.where(fitness == numpy.max(fitness))
max_fitness_idx = max_fitness_idx[0][0]
parents[parent_num, :] = pop[max_fitness_idx, :]
fitness[max_fitness_idx] = -99999999999
return parents
Listing 4-5Selecting the Best Parents According to Fitness Values
根据变量 num _ parents _ mating 中定义的所需父代数量,创建“parents”空数组来保存它们。在循环内部,该函数遍历当前群体中的解决方案,以获得具有最高适应值的解决方案的索引,因为它是要选择的最佳解决方案。该指数存储在“max_fitness_idx”变量中。基于这个索引,对应于它的解被返回到“parents”数组。为了避免再次选择该解决方案,其适应度值被设置为–99999999999,这是一个非常小的值。该值使得解决方案不太可能被再次选择。在选择了所需的双亲数量后,双亲数组返回,如表 4-6 所示。请注意,这三个父母是当前群体中基于其适应值的最佳个体,适应值分别为 63.41、18.24、17.07 和 16.0。
表 4-6
从第一个群体中选择的父母
| |W1
|
W2
|
W3
|
W4
|
W5
|
W6
|
| --- | --- | --- | --- | --- | --- | --- |
| 父母 1 | –0.64 | –2.86 | Two point nine three | –1.4 | –1.2 | Zero point three one |
| 父母 2 | Three point zero one | –2.75 | Three point two seven | –0.72 | Zero point seven five | Zero point zero one |
| 父母 3 | One point nine seven | Zero point five one | Zero point five three | –1.57 | –2.36 | Two point three |
| 父母 4 | Two point one three | Two point nine seven | Three point six | Three point seven nine | Zero point two nine | Three point five two |
下一步是使用选择的父母进行交配,以产生后代。根据 GA.crossover 函数,配对从交叉操作开始。这个函数接受父母和后代的大小。它使用后代大小来学习从父母产生的后代数量。该功能根据 GA 模块内的清单 4-6 实现。
def crossover(parents, offspring_size):
offspring = numpy.empty(offspring_size)
# The point at which crossover takes place between two parents. Usually, it is at the center.
crossover_point = numpy.uint8(offspring_size[1]/2)
for k in range(offspring_size[0]):
# Index of the first parent to mate.
parent1_idx = k%parents.shape[0]
# Index of the second parent to mate.
parent2_idx = (k+1)%parents.shape[0]
# The new offspring will have its first half of its genes taken from the first parent.
offspring[k, 0:crossover_point] = parents[parent1_idx, 0:crossover_point]
# The new offspring will have its second half of its genes taken from the second parent.
offspring[k, crossover_point:] = parents[parent2_idx, crossover_point:]
return offspring
Listing 4-6
Crossover
因为我们使用单点交叉,我们需要指定交叉发生的点。选择该点以将解分成相等的两半。然后我们需要选择双亲来杂交。这些父代的索引存储在 parent1_idx 和 parent2_idx 中。双亲以类似于环的方式被选择。首先选择索引 0 和 1 来产生两个后代。如果还有剩余的后代要产生,那么我们选择父母 1 和 2 来产生另外两个后代。如果我们需要更多的后代,那么我们选择指数为 2 和 3 的下两个父母。通过索引 3,我们到达最后一个父节点。如果我们需要产生更多的后代,那么我们选择索引为 3 的父代,然后回到索引为 0 的父代,依此类推。应用交叉后的后代存储到后代变量中。表 4-7 显示了该变量的内容。
表 4-7
杂交后的后代
| |W?? 1
|
W2
|
W3
|
W4
|
W5
|
W6
|
| --- | --- | --- | --- | --- | --- | --- |
| 后代 1 | –0.64 | –2.86 | Two point nine three | –0.72 | Zero point seven five | Zero point zero one |
| 后代 2 | Three point zero one | –2.75 | Three point two seven | –1.57 | –2.36 | Two point three |
| 后代 3 | One point nine seven | Zero point five one | Zero point five three | Three point seven nine | Zero point two nine | Three point five two |
| 后代 4 | Two point one three | Two point nine seven | Three point six | –1.4 | –1.2 | Zero point three one |
下一步是使用清单 4-7 中实现的 GA 模块内的变异函数,将第二个 GA 变体(变异)应用于交叉的结果。该函数接受交叉子代,并在应用统一变异后返回它们。
def mutation(offspring_crossover):
# Mutation changes a single gene in each offspring randomly.
for idx in range(offspring_crossover.shape[0]):
# The random value to be added to the gene.
random_value = numpy.random.uniform(-1.0, 1.0, 1)
offspring_crossover[idx, 4] = offspring_crossover[idx, 4] + random_value
return offspring_crossover
Listing 4-7
Mutation
它遍历每个后代,并添加一个统一生成的随机数,比如从-1.0 到 1.0。然后将该随机数与后代的一个随机选择的索引(例如,索引 4)一起添加到基因中。请注意,该索引可以更改为任何其他索引。结果存储在变量“后代交叉”中,并由表 4-8 中的函数返回。在这一点上,我们已经成功地从四个选择的亲本中产生了四个后代,并准备好创建下一代的新种群。
表 4-8
突变的结果
| |W?? 1
|
W2
|
W3
|
W4
|
W5
|
W6
|
| --- | --- | --- | --- | --- | --- | --- |
| 后代 1 | –0.64 | –2.86 | Two point nine three | –0.72 | One point six six | Zero point zero one |
| 后代 2 | Three point zero one | –2.75 | Three point two seven | –1.57 | –1.95 | Two point three |
| 后代 3 | One point nine seven | Zero point five one | Zero point five three | Three point seven nine | Zero point four five | Three point five two |
| 后代 4 | Two point one three | Two point nine seven | Three point six | –1.4 | –1.58 | Zero point three one |
注意,遗传算法是一种基于随机的优化技术。它试图通过对当前解决方案进行一些随机更改来增强它们。因为这些变化是随机的,我们不确定它们会产生更好的解决方案。为此,在新的种群中,最好保留以前的最佳解(父代)。在最坏的情况下,当所有新的后代都比父母更差时,我们将继续使用这些父母。这样一来,我们保证新一代至少会保留以前的好成绩,不会变得更差。新的群体将从先前的父母那里得到它的前四个解决方案。最后四个解来自应用交叉和变异后产生的后代。
表 4-9 给出了第一代所有解(父代和子代)的适合度。以前的最高适合度是 18.24112489,但现在是 31.24115151589。这意味着随机变化朝着更好的解决方案发展。这太棒了。但是这些结果可以通过更多的世代来加强。经过 10000 次迭代后,结果达到 40000 以上,如图 4-6 所示。
图 4-6
适应值与 10,000 次迭代
表 4-9
新群体中所有解的适应值
| |解决方案 1
|
解决方案 2
|
解决方案 3
|
解决方案 4
|
解决方案 5
|
解决方案 6
|
解决方案 7
|
解决方案 8
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 健康 | Eighteen point two four | Seventeen point zero seven | Sixteen | Fourteen point four | –8.46 | Thirty-one point seven three | Six point one | Twenty-four point zero nine |
完全实现
清单 4-8 中给出了实现 GA 的完整代码。
import numpy
import GA
#The y=target is to maximize this equation ASAP:
# y = w1x1+w2x2+w3x3+w4x4+w5x5+6wx6
# where (x1,x2,x3,x4,x5,x6)=(4,-2,3.5,5,-11,-4.7)
# What are the best values for the 6 weights w1 to w6?
# We are going to use the GA for the best possible values #after a number of generations.
# Inputs of the equation.
equation_inputs = [4,-2,3.5,5,-11,-4.7]
# Number of the weights we are looking to optimize.
num_weights = 6
#GA parameters:
# Mating pool size
# Population size
sol_per_pop = 8
num_parents_mating = 4
# Defining the population size.
pop_size = (sol_per_pop,num_weights) # The population will have sol_per_pop chromosome where each chromosome has num_weights genes.
#Creating the initial population.
new_population = numpy.random.uniform(low=-4.0, high=4.0, size=pop_size)
print(new_population)
num_generations = 10,000
for generation in range(num_generations):
print("Generation : ", generation)
# Measuring the fitness of each chromosome in the population.
fitness = GA.cal_pop_fitness(equation_inputs, new_population)
# Selecting the best parents in the population for mating.
parents = GA.select_mating_pool(new_population, fitness,
num_parents_mating)
# Generating next generation using crossover.
offspring_crossover = GA.crossover(parents,
offspring_size=(pop_size[0]-parents.shape[0], num_weights))
# Adding some variations to the offspring using mutation.
offspring_mutation = GA.mutation(offspring_crossover)
# Creating the new population based on the parents and offspring.
new_population[0:parents.shape[0], :] = parents
new_population[parents.shape[0]:, :] = offspring_mutation
# The best result in the current iteration.
print("Best result : ", numpy.max(numpy.sum(new_population*equation_inputs, axis=1)))
# Getting the best solution after iterating finishing all generations.
#At first, the fitness is calculated for each solution in the final generation.
fitness = GA.cal_pop_fitness(equation_inputs, new_population)
# Then return the index of that solution corresponding to the best fitness.
best_match_idx = numpy.where(fitness == numpy.max(fitness))
print("Best solution : ", new_population[best_match_idx, :])
print("Best solution fitness : ", fitness[best_match_idx])
Listing 4-8The Complete Code for Optimizing a Linear Equation with Six Parameters
GA 模块的实现如清单 4-9 所示。
import numpy
def cal_pop_fitness(equation_inputs, pop):
# Calculating the fitness value of each solution in the current population.
# The fitness function calcuates the SOP between each input and its corresponding weight.
fitness = numpy.sum(pop*equation_inputs, axis=1)
return fitness
def select_mating_pool(pop, fitness, num_parents):
# Selecting the best individuals in the current generation as parents for producing the offspring of the next generation.
parents = numpy.empty((num_parents, pop.shape[1]))
for parent_num in range(num_parents):
max_fitness_idx = numpy.where(fitness == numpy.max(fitness))
max_fitness_idx = max_fitness_idx[0][0]
parents[parent_num, :] = pop[max_fitness_idx, :]
fitness[max_fitness_idx] = -99999999999
return parents
def crossover(parents, offspring_size):
offspring = numpy.empty(offspring_size)
# The point at which crossover takes place between two parents. Usually it is at the center.
crossover_point = numpy.uint8(offspring_size[1]/2)
for k in range(offspring_size[0]):
# Index of the first parent to mate.
parent1_idx = k%parents.shape[0]
# Index of the second parent to mate.
parent2_idx = (k+1)%parents.shape[0]
# The new offspring will have its first half of its genes taken from the first parent.
offspring[k, 0:crossover_point] = parents[parent1_idx, 0:crossover_point]
# The new offspring will have its second half of its genes taken from the second parent.
offspring[k, crossover_point:] = parents[parent2_idx, crossover_point:]
return offspring
def mutation(offspring_crossover):
# Mutation changes a single gene in each offspring randomly.
for idx in range(offspring_crossover.shape[0]):
# The random value to be added to the gene.
random_value = numpy.random.uniform(-1.0, 1.0, 1)
offspring_crossover[idx, 4] = offspring_crossover[idx, 4] + random_value
return offspring_crossover
Listing 4-9
GA Module
NSGA 二号
遗传算法和 NSGA-II 的主要区别是在给定群体(即新一代的父母)中选择最佳个体的方式。在遗传算法中,单个值用于选择最佳个体。这是由适应度函数生成的适应度值。适应值越高,解决方案/个人越好。对于 NSGA-II,不存在单个值,而是由多个目标函数生成的多个值。我们如何基于这些多重价值做出选择,记住所有这些目标具有同等的重要性?必须有一种不同于常规遗传算法的方法来选择最佳个体。NSGA-II 根据两个指标选择其父母或最佳个体:
-
统治力。
-
拥挤距离。
我们将在讨论中使用的例子是关于一个想买一件衬衫的人。这个人有两个目标需要通过这件衬衫来实现:
-
低成本(0 美元到 85 美元之间)。
-
之前买家的差评(0 到 5 分之间)。
成本以美元计算,反馈以 0 到 5 之间的实数计算,其中 0 为最佳反馈,5 为最差反馈。这意味着两个目标函数是最小化。假设只有 8 个数据样本,如表 4-10 所示;我们将用它们来开始。
表 4-10
数据样本
|身份
|
成本美元
|
不良反馈
|
| --- | --- | --- |
| A | Twenty | Two point two |
| B | Sixty | Four point four |
| C | Sixty-five | Three point five |
| D | Fifteen | Four point four |
| E | Fifty-five | Four point five |
| F | Fifty | One point eight |
| G | Eighty | Four |
| H | Twenty-five | Four point six |
NSGA 二号协议步骤
NSGA-II 遵循传统遗传算法的一般步骤。变化是不使用适应值来为下一代选择最佳解决方案(父代);相反,它使用优势和拥挤距离。以下是 NSGA 二号的一般停靠点:
-
从数据中选择第 0 代的初始群体解决方案。
-
使用非优势排序将解决方案分成不同的级别。
-
选择 1 级非支配前沿的最佳解作为亲本进行交配,为下一代产生后代。(如果最后使用的级别内的所有解决方案都被完全选择,没有剩余,则直接转到步骤 5。)
-
如果从最后使用的级别中选择一个解决方案子集作为父级,那么您必须计算该级别中解决方案的拥挤距离,根据拥挤距离将这些解决方案按差序排序,并从顶部选择剩余解决方案的数量。
-
使用选择的父母来生产后代。
-
对所选父代的锦标赛选择。
-
GA 变异(即交叉和变异)对锦标赛结果的影响。这将产生下一代的新后代。
-
-
重复步骤 2 到 5,直到达到最大迭代次数。
请注意,您不应该期望在当前理解所有这些步骤。但是不要担心:当你经历每一步的细节时,事情会变得更加容易和清晰。这些步骤总结在图 4-7 中。
图 4-7
NSGA 二号协议步骤
NSGA 与遗传算法没有什么不同,但增加了一些运算,使其适用于多目标问题。图 4-8 突出了 GA 和 NSGA 的区别。遗传算法中计算适应值的步骤被扩展到 NSGA 算法中的多个步骤,从非支配排序开始直到锦标赛选择。在确定交配池将使用什么解决方案后,这两个算法是相似的。
图 4-8
GA 对 NSGA
遗传算法的第一步通常是选择初始群体的解/个体。假设总体规模为 8,这意味着总体中要使用 8 个样本。这意味着表 4-10 中的所有样本将用于初始人群。下一步是选择这个群体中的最佳解作为父母,使用优势概念产生下一代的后代。
优势
NSGA-II 中的支配地位帮助我们选择作为父母的最佳解决方案集。据说这些解决方案优于其他解决方案。也就是说,它们优于所有其他解决方案。
实际上,在所有目标上,这些解决方案并不总是比其他解决方案差或更差。我们如何在数据中找到最佳的解决方案?为了说明一个解决方案优于另一个解决方案,可以使用以下规则:
当且仅当,解 X 优于解 Y
-
在所有目标函数中,解决方案 X 不比解决方案 Y 差
-
至少在一个目标函数上,方案 X 优于方案 Y。
除了说解 X 优于解 Y,我们还可以说:
-
解 X 不受解 y 支配。
-
解 Y 受解 x 支配。
-
解 Y 不支配解 x。
请注意,如果前面的任何条件都不满足,那么解决方案 X 不会优于解决方案 y。这意味着没有解决方案比另一个更好,并且它们之间存在权衡。还要注意,当解 X 优于解 Y 时,意味着解 X 优于解 Y。
不满足前面两个条件中至少一个的所有解的集合称为非支配集。之所以这样叫,是因为该集合中没有一个解优于另一个解。寻找非支配集的步骤如下:
-
选择索引为 I 的解决方案,其中 I 从 1 开始,对应于第一个解决方案。
-
检查该解决方案相对于数据中所有其他解决方案的优势。
-
如果发现一个解支配那个解,那么停止,因为它不可能在非支配集中。直接转到步骤 5。
-
如果没有解支配那个解,那么把它加到非支配集。
-
将 I 增加 1,并重复步骤 2 至 4。
使用非支配排序,解决方案被分成多个集合。每个集合称为一个非支配锋。这些锋面按级别排序,第一个非支配锋面在级别 1,第二个非支配锋面在级别 2,依此类推。根据表 4-10 中的例子,让我们应用这些步骤来找出第一级的非主导锋。
-
从解决方案 A 开始,将其与解决方案 B 进行比较,我们发现 A 在第一个目标(成本)上优于 B,因为 A 的成本为 20 美元,低于(即优于)B 的成本 60 美元。此外,在第二个目标(反馈)中,A 优于 B,因为 A 的反馈为 2.2,低于(即优于)B 的反馈 4.4。因此,在所有目标上,A 都优于 B。满足使方案 A 支配方案 B 的条件。但是我们不能断定 A 是非支配集的一员,我们还得等到将 A 与所有其他解进行比较。
-
比较 A 和 C,很明显 A 在所有目标上都优于 C,因为 A 的成本和反馈都小于 C。结果,C 不支配 A(即 A 支配 C)。我们仍然需要探索下一个解决方案来决定 A 是否是非支配集的成员。
-
比较 A 和 D,我们发现 A 的反馈 2.2 比 D 的反馈 4.4 好。但是 A 的 20 美元成本比 D 的 15 美元成本更差。因此,每种解决方案在一个目标上都优于其他方案。因此,解决方案 D 不满足两个支配条件。因此,我们可以得出结论,D 不支配 A,A 也不支配 D。我们必须再次对照剩余的解决方案检查 A,以了解其决策。
-
比较 A 和 E,很明显 A 在所有目标上都优于 E。因此,A 支配 e,让我们比较 A 和下一个解 f。
-
比较 A 和 F,没有一个解决方案比另一个更好。这与比较 A 和 d 的情况相同,因此,F 并不支配 A,我们必须将 A 和其他解进行比较。
-
比较 A 和 G,A 在所有目标上都优于 G,因为 A 的成本(20 美元)小于 G 的成本(80 美元),并且 A 的反馈(2.2)也优于 G 的反馈(4.0)。让我们来看看最终的解决方案。
-
比较 A 和 H,在所有解决方案中,A 都优于 H。结果 H 并不支配 A,在检查了 A 在所有解中的支配地位后,似乎没有一个解支配 A,所以,A 被视为非支配集的一员。当前的非支配集是 P={A}。让我们转向下一个解决方案。
-
关于方案 B 和 C,很明显方案 A 占优。因此,我们可以直接检查解决方案 d 的优势。
-
用 A 比较 D,我们发现 D 在第一个目标(成本)上优于 D,因为 D 的成本是 15 美元,小于 A 的成本 20 美元。关于第二个目标,D 比 A 差,因为 D 的反馈 4.4 大于 A 的反馈 2.2。因为方案 A 并不优于方案 D,我们必须将方案 D 与下一个方案进行比较。
-
比较 D 和 B,我们发现在第一个目标中 D 比 B 好,在第二个目标中它们是相等的。结果,B 并不支配 D,我们必须对照剩余的解来检查 D,以了解它的决定。
-
比较 D 和 C,在第一个目标中 D 比 C 好,但是在第二个目标中 D 比 C 差。使 C 支配 D 的条件不满足。结果,C 并不支配 D,我们必须对照下一个解来检查 D。
-
比较 D 和 E,我们发现 D 在所有目标上都优于 E。我们可以得出结论,E 并不支配 D,继续比较 D 和下一个解。
-
比较 D 和 F,D 的 15 美元成本比 F 的 50 美元成本更小(更好)。因为解 F 至少在一个目标上比 D 差,所以我们可以停下来得出结论,F 并不支配 D。让我们将 D 与下一个解进行比较。
-
比较 D 和 G,同样的场景在 F 身上重演。d 的 15 美元成本比 G 的 80 美元成本小(好)。因为方案 G 至少在一个目标上比 D 差,所以我们可以得出结论,G 并不优于 D。让我们将 D 与下一个方案进行比较。
-
比较 D 和 H,H 在所有目标上都比 D 差,因此 H 不支配 D。在这一点上,我们可以得出结论,没有解决方案支配解决方案 D,并且它包含在非支配集中。当前的非支配集是 P={A,D}。让我们转向下一个解决方案。
-
使用 E,将其与 A 进行比较,我们发现 A 在所有目标上都优于 E,因为 A 的 20 美元成本小于 E 的 55 美元成本,并且 A 的反馈 2.2 优于 E 的反馈 4.5。由此,我们可以停下来得出结论,A 支配 E;e 不能包含在非支配集合中。
-
和 F 一起工作,和 A 比较,我们发现 A 在第一个目标上比 F 好,而在第二个目标上 F 比 A 好。因此,没有一种解决方案能支配另一种解决方案。我们仍然需要比较 F 和剩余的解来做决定。
-
将 F 与所有解进行比较后,不存在支配解 F 的解。因此,F 包含在非支配集中。当前的非支配集是 P={A,D,F}。让我们转向下一个解决方案。
-
与 G 一起工作,并将其与所有解决方案进行比较,我们发现解决方案 A、C 和 F 占主导地位。因此,G 不能包含在非支配集中。让我们转向最终解决方案。
-
使用最终的解决方案 H,通过将其与所有解决方案进行比较,我们发现解决方案 A 和 D 占优势。因此,H 不能包含在非支配集中。此时,我们已经检查了所有解决方案的优势。
将每对解比较在一起后,最终的非支配集是 P={A,D,F}。这是第一级非支配锋。在所有目标上,同一战线中的任何解决方案都不会比同一战线中的任何其他解决方案更好。这就是为什么它被称为非支配集,因为没有一个解支配另一个解。
清单 4-10 给出了检查给定解决方案优势的 Python 代码。给定一个解的索引,它返回支配它的解的 id。它使用 pandas DataFrame (DF)对每个解决方案的目标值以及它们的 id 进行排序。这有助于引用解决方案 ID。创建这个 DF 的一个简单方法是将数据插入 Python 字典,然后将其转换成 pandas DF。
import numpy
import pandas
d = {'A': [20, 2.2],
'B': [60, 4.4],
'C': [65, 3.5],
'D': [15, 4.4],
'E': [55, 4.5],
'F': [50, 1.8],
'G': [80, 4.0],
'H': [25, 4.6]}
df = pandas.DataFrame(data=d).T
data_labels = list(df.index)
data_array = numpy.array(df).T
# ****Specify the index of the solution here****
sol_idx = 1
sol = data_array[:, sol_idx]
obj1_not_worse = numpy.where(sol[0] >= data_array[0, :])[0]
obj2_not_worse = numpy.where(sol[1] >= data_array[1, :])[0]
not_worse_candidates = set.intersection(set(obj1_not_worse), set(obj2_not_worse))
obj1_better = numpy.where(sol[0] > data_array[0, :])[0]
obj2_better = numpy.where(sol[1] > data_array[1, :])[0]
better_candidates = set.union(set(obj1_better), set(obj2_better))
dominating_solutions = list(set.intersection(not_worse_candidates,
better_candidates))
if len(dominating_solutions) == 0:
print("No solution dominates solution", data_labels[sol_idx], ".")
else:
print("Labels of one or more solutions dominating this solution : ", end="")
for k in dominating_solutions:
print(data_labels[k], end=",")
Listing 4-10Returning Dominating Solutions
对于给定的解,检查支配条件。对于第一个条件,在“not_worse_candidates”变量中返回所有目标中不差于当前解决方案的解决方案的索引。第二个条件搜索至少在一个目标上比当前解决方案更好的解决方案。满足第二个条件的解在“更好的候选”解中返回。要使一个给定的解决方案优于另一个,这两个条件都必须满足。因此,“dominating_solutions”变量只返回满足这两个条件的解。
前面的三个解决方案优于剩下的所有五个解决方案。换句话说,在 1 级非支配前沿的解决方案比所有其他前沿的任何解决方案都好。在第一级的第一个非支配前沿中没有被选中的其他五个解呢?我们将继续使用群体中剩余的样本来进一步寻找下一个非显性水平。
寻找非支配集的步骤将被重复,以在第二层寻找非支配前沿,但是在去除先前在群体的第一层中选择的三个解之后。剩余解的集合是{B,C,E,G,H}。让我们寻找下一个非支配前沿:
-
从方案 B 开始,检查它对 C 的支配地位,B 的反馈 4.4 比 C 的反馈 3.5 差。根据第一个目标,B 的成本 60 美元比 C 的成本 65 美元要好。因此,方案 C 不优于方案 B。我们仍然需要等待,直到我们将 B 与其余的方案进行比较。
-
比较 B 和 E,B 在第二个目标中比 E 好,因为 B 的反馈是 4.4,E 的反馈是 4.5。结果,方案 E 不优于方案 b。让我们检查下一个方案。
-
比较 B 和 G,我们发现在第一个目标中 B 比 G 好,因为 B 的成本是 60 美元,G 的成本是 80 美元。结果,方案 G 不优于方案 b。让我们检查下一个方案。
-
比较 B 和 H,我们发现在第二个目标中 B 比 H 好,因为 B 的反馈是 4.4,H 的反馈是 4.6。结果,解 H 非支配解 B。在将 B 与所有解进行比较并发现没有解支配它之后,我们可以得出结论,B 包含在级别 2 的非支配前沿中。级别 2 集合现在是 P'={B}。让我们来检查第二个解决方案在剩余的解决方案集中的优势。
-
比较下一个解决方案 C 和 B,在第二个目标中,C 比 B 好,因为 C 的反馈是 3.5,B 的反馈是 4.4。因此,方案 B 不优于方案 c。
-
将 C 与其余解比较,没有支配 C 的解,它将包含在第二层的非支配前沿中,即 P'={B,C}。让我们转向下一个解决方案。
-
将下一个解 E 与群体中所有剩余的解进行比较,我们发现没有一个解支配解 E。因此,E 将包含在级别 2 的非支配前沿中,即 P'={B,C,E}。让我们转向下一个解决方案。
-
将下一个解决方案 G 与群体中所有剩余的解决方案进行比较,我们发现解决方案 C 优于解决方案 G,因为 C 在所有目标上都优于 G。因此,解 G 不包括在二级非支配锋中。让我们转向下一个解决方案。
-
将最后一个解 H 与群体中所有剩余的解进行比较,我们发现没有一个解支配解 H。因此,它将包含在级别 2 的非支配前沿中,这将是 P'={B,C,E,H}。
这是第二层非支配前线的终点。剩余解的集合是{G}。这个集合将被用来寻找 3 级非支配锋。因为只剩下一个解,所以它将被单独添加到第三层的非支配前沿,成为 P"={G}。此时,我们成功地将数据分为三个非优势水平,如表 4-11 所示。
表 4-11
将数据分为三个非优势水平的结果
|水平
|
解决方法
|
| --- | --- |
| one | {A,D,F} |
| Two | {B,C,E,H} |
| three | {G} |
请注意,级别 I 中的解优于级别 i + 1 中的解。也就是说,级别 1 的解决方案优于级别 2 的解决方案,级别 2 的解决方案优于级别 3 的解决方案,以此类推。因此,在选择为人父母的最佳解决方案时,我们将从第一个层次开始选择。如果第一层中可用解的数量少于所需父解的数量,那么我们从第二层中选择剩余的父解,依此类推。
在我们的问题中,人口数量是 8。为了产生同样大小的新一代,我们需要选择其一半的人口作为父母;剩下的一半是父母交配产生的后代。首先,我们需要选出最好的四个父母。
第一个非支配水平只有三个解。因为我们需要四个家长,所以我们将选择所有这三个解决方案。结果现在的父母是{A,D,F}。我们应该从级别 2 中选择剩余的父代。
级别 2 有四个解决方案,我们只需要选择一个。重要的问题是,我们应该从级别 2 中选择哪个解决方案?用于评估同一非支配锋内的解的度量是拥挤距离。接下来,我们将学习如何计算第二层前沿内解决方案的拥挤距离。
拥挤距离
拥挤距离是用于在相同的非支配前沿内对解决方案进行优先排序的度量。以下是计算和使用拥挤距离的步骤:
-
对于每个目标函数,按差序对该级别内的解决方案集进行排序。
-
对于异常值处的两个解决方案(即最右边和最左边的解决方案),将其拥挤距离设置为无穷大。
-
对于中间解,拥挤距离根据等式 4-7 计算。
-
对于每个解决方案,对所有目标的拥挤距离求和。
-
按降序对解决方案进行排序,以从最高到最低拥挤距离选择解决方案。
(方程式 4-7)
根据一个目标函数对解排序后, n 指其位置。 m 是指用于计算拥挤距离的目标函数的数目。是根据目标 m 的解 n 的拥挤距离,
是解 n 的目标 m 的值,
是目标 m 的最大值,
是目标 m 的最小值。
对于最小化目标,按差序排序解是指按降序排序,其中根据目标,最小(即,最佳)解在最左边,最大(即,最差)解在最右边。
因为异常值处的两个解决方案的拥挤距离等于无穷大,所以我们可以开始计算中间解决方案的拥挤距离。
表 4-10 中的问题数据如下,以便于计算拥挤距离。
|ID
|
成本$
|
反馈不佳
|
| --- | --- | --- |
| A | Twenty | Two point two |
| B | Sixty | Four point four |
| C | Sixty-five | Three point five |
| D | Fifteen | Four point four |
| E | Fifty-five | Four point five |
| F | Fifty | One point eight |
| G | Eighty | Four |
| H | Twenty-five | Four point six |
图 4-9 总结了根据成本目标计算方案 E 和 B 的拥挤距离的参数值。
图 4-9
水平 2 解的第一目标拥挤距离
同样,图 4-10 显示了如何根据反馈目标计算解决方案 B 和 E 的拥挤距离。
图 4-10
水平 2 解的第一目标拥挤距离
将两个目标的拥挤距离相加,并按降序排列,结果如表 4-12 所示。如果我们只需要作为父代的第二层的一个解,那么它将是表 4-12 中拥挤距离总和按降序排序后的第一个解。该解就是解 C。因此,所选解的集合将是{A,D,F,C}。请注意,并非所有这些解决方案都将用于生成新的后代,因为它们可能会被锦标赛选择过滤掉。但所有这些解决方案都将用于构成新一代解决方案的前半部分。另一半将来自从锦标赛中选出的父母的交配。
表 4-12
两个目标函数的水平 2 解的拥挤距离之和
|身份
|
总和
|
| --- | --- |
| C | 无穷 |
| H | 无穷 |
| E | Zero point four four |
| B | Zero point three |
锦标赛选择
在锦标赛选择中,我们从所选的父母中创建解决方案对。从每一对中,他们和获胜者之间的比赛将进一步用于交叉和变异。所有可能的对是(A,D),(A,F),(A,C),(D,C)和(F,C)。
以下是锦标赛获胜者的评选方式:
-
如果两个解决方案来自不同的非支配级别,那么来自高优先级的解决方案将胜出。
-
如果两个解来自相同的非优势水平,那么胜者将是对应于更高拥挤距离的那个。
让我们考虑第一对(A,D)。因为他们来自同一个级别,我们将使用他们的拥挤距离来学习获胜者。因为我们还没有计算第一级的拥挤距离,所以需要先计算一下。
图 4-11 显示了根据两个目标,第 1 级解决方案的最终拥挤距离。关于第一对(A,D),获胜者是 D,因为它比 A 具有更高的拥挤距离。对于剩余的锦标赛,获胜者是 F,A,D 和 F。这三个唯一的解 A,D 和 F 用于生成四个后代。
图 4-11
两个目标函数的水平 1 解的拥挤距离之和
交叉
假设我们从对(A,D)、(A,F)、(D,F)和(F,A)中选择四个新的解,其中后代的前一半和后一半基因分别取自每对中的第一个和最后一个解。交叉的结果如表 4-13 所示。
表 4-13
锦标赛获胜者之间的交叉
|产物
|
成本美元
|
反馈
|
| --- | --- | --- |
| (甲、丁) | Twenty | Four point four |
| (阿、福) | Twenty | One point eight |
| (D,F) | Fifteen | One point eight |
变化
变异将应用于交叉的结果。假设我们通过在每个解决方案的前半部分随机添加一个介于–10 和 10 之间的数字来应用突变。变异操作的结果如表 4-14 所示。
表 4-14
交叉输出的变异
|产物
|
成本美元
|
反馈
|
| --- | --- | --- |
| (B,D) | Twenty-seven | Four point four |
| (B,E) | Twenty-five | One point eight |
| (D,E) | Ten | One point eight |
之后,我们已经成功生产了下一代 1 的八种解决方案。前四个解是由非支配排序和拥挤距离产生的。剩下的四个解决方案是我们刚刚通过锦标赛选择、交叉和变异产生的,如表 4-14 所示。第 1 代新人口的解决方案见表 4-15 。
表 4-15
第 1 代解决方案
|身份
|
成本美元
|
反馈
|
| --- | --- | --- |
| A | Twenty | Two point two |
| D | Fifteen | Four point four |
| F | Fifty | One point eight |
| C | Sixty-five | Three point five |
| K | Twenty-seven | Four point four |
| L | Twenty-five | One point eight |
| M | Ten | One point eight |
| 普通 | Forty-five | Two point two |
至此,我们已经完成了 NSGA-II 多目标进化算法的所有步骤。下一步是重复 NSGA-II 的步骤 2 到 5,直到达到预定数量的代/迭代。第一代后,算法找到了解 M,它优于上一代种群中的所有解。经过多代,该算法可能会找到更好的解决方案。
用遗传算法优化人工神经网络
在第四章中,使用四类 Fruits 360 数据集训练人工神经网络,而不使用学习算法。因此,精确度较低,不超过 45%。除了使用 Python 实现之外,在理解了 GA 如何基于数值示例工作之后,本节使用 GA 通过更新其权重(参数)来优化 ANN。
GA 为一个给定的问题创建多个解决方案,并通过若干代进化它们。每个解决方案都包含所有可能有助于增强结果的参数。对于人工神经网络,所有层中的权重有助于实现高精度。因此,GA 中的单个解将包含 ANN 中的所有权重。根据图 4-7 ,ANN 有四层(一层输入,两层隐藏,一层输出)。任何层中的任何重量都是同一解决方案的一部分。此网络的单个解决方案将包含总权重数,等于 102×150+150×60+60×4=24,540。如果总体有八个解,每个解有 24,540 个参数,则整个总体的参数总数为 24,540×8=196,320。
查看图 4-8 ,网络参数为矩阵形式,因为这使得人工神经网络的计算更加容易。对于每一层,都有一个相关的权重矩阵。只需将输入矩阵乘以给定层的参数矩阵,即可返回该层的输出。遗传算法中的染色体是 1D 向量,因此我们必须将权重矩阵转换成 1D 向量。
因为矩阵乘法是处理 ANN 的一个很好的选择,所以在使用 ANN 时,我们仍将以矩阵形式表示 ANN 参数。图 4-12 总结了使用遗传算法和人工神经网络的步骤。
图 4-12
用遗传算法优化神经网络参数
群体中的每个解决方案将有两个表示。第一个是用于 GA 的 1D 向量,第二个是用于 ANN 的矩阵。因为三层有三个权重矩阵(两个隐藏+一个输出),所以将有三个向量,每个矩阵一个。因为 GA 中的解被表示为单个 1D 向量,所以这三个单独的 1D 向量将被连接成单个 1D 向量。每个解将被表示为长度为 24,540 的向量。清单 4-11 保存了“mat_to_vector”函数的 Python 代码,该函数将群体内所有解的参数从矩阵转换为向量。
创建一个名为“pop_weights_vector”的空列表变量来保存所有解的向量。该函数接受一组解决方案,并在它们之间循环。对于每个解决方案,都有一个内部循环来遍历它的三个矩阵。对于每个矩阵,使用“numpy.reshape”函数将其转换为向量,该函数接受输入矩阵和矩阵将被整形到的输出大小。变量“curr_vector”接受单个解决方案的所有向量。生成所有向量后,它们被追加到“pop_weights_vector”变量中。
注意,我们对属于同一解决方案的向量使用了“numpy.extend”函数,对属于不同解决方案的向量使用了“numpy.append”。原因是“numpy.extend”将属于同一解决方案的三个向量中的数字连接在一起。换句话说,为两个列表调用这个函数将返回一个新的单个列表,其中包含两个列表中的数字。这适合于为每个解决方案创建一个 1D 染色体。但是“numpy.append”将为每个解决方案返回三个列表。为两个列表调用它,它返回一个新的列表,这个列表被分成两个子列表。这不是我们的目标。最后,函数“mat_to_vector”以 NumPy 数组的形式返回群体解,以便于以后操作。
def mat_to_vector(mat_pop_weights):
pop_weights_vector = []
for sol_idx in range(mat_pop_weights.shape[0]):
curr_vector = []
for layer_idx in range(mat_pop_weights.shape[1]):
vector_weights = numpy.reshape(mat_pop_weights[sol_idx, layer_idx], newshape=(mat_pop_weights[sol_idx, layer_idx].size))
curr_vector.extend(vector_weights)
pop_weights_vector.append(curr_vector)
return numpy.array(pop_weights_vector)
Listing 4-11
Parameters Matrix Conversion into Vector
在将所有的解从矩阵转换为向量并将它们连接在一起之后,我们准备好按照图 4-2 来执行 GA 步骤。图 4-2 中除适应值计算之外的所有步骤都与之前讨论的遗传算法实施相似。
诸如 ANN 之类的分类器的常见适应度函数之一是准确性。它是正确分类的样本与样本总数之间的比率。它是根据等式 4-8 计算的。按照图 4-12 中的步骤计算每个解的分类精度。
(方程式 4-8)
每个解的单个 1D 向量被转换回三个矩阵,每层一个矩阵(两个隐藏和一个输出)。使用清单 4-12 中定义的“vector_to_mat”函数进行转换。它逆转了先前所做的工作。但有一个重要的问题:如果给定解的向量只是一个片段,我们如何将它分成三个不同的部分,每个部分代表一个矩阵?输入层和隐含层之间的第一个参数矩阵的大小为 102×150。当转换成向量时,它的长度将是 15,300。因为根据清单 4-11 ,它是要插入“curr_vector”变量的第一个向量,那么它将从索引 0 开始,到索引 15,299 结束。“mat_pop_weights”用作“vector_to_mat”函数的参数,以便了解每个矩阵的大小。不要求包含最近的权重;从中只使用了矩阵的大小。
def vector_to_mat(vector_pop_weights, mat_pop_weights):
mat_weights = []
for sol_idx in range(mat_pop_weights.shape[0]):
start = 0
end = 0
for layer_idx in range(mat_pop_weights.shape[1]):
end = end + mat_pop_weights[sol_idx, layer_idx].size
curr_vector = vector_pop_weights[sol_idx, start:end]
mat_layer_weights = numpy.reshape(curr_vector, newshape=(mat_pop_weights[sol_idx, layer_idx].shape))
mat_weights.append(mat_layer_weights)
start = end
return numpy.reshape(mat_weights, newshape=mat_pop_weights.shape)
Listing 4-12
Solution Vector Conversion into Matrices
对于同一解决方案中的第二个向量,它是转换一个大小为 150×60 的矩阵的结果。因此,向量长度为 9000。这个向量被插入到“curr_vector”变量中,正好在长度为 15,300 的前一个向量之前。因此,它将从索引 15,300 开始,到索引 15,300+9,000–1 = 24,299 结束。使用–1 是因为 Python 从 0 开始索引。对于从大小为 60×4 的参数矩阵创建的最后一个向量,其长度为 240。因为它被添加到“curr_vector”变量中正好在长度为 9000 的前一个向量之后,所以它的索引将在它之后开始。也就是说,它的开始索引是 24,300,结束索引是 24,300+240–1 = 24,539。所以,我们可以成功地将向量还原成原来的三个矩阵。
为每个解决方案返回的矩阵用于预测所用数据集中 1,962 个样本中每个样本的类别标签,以计算精确度。这是根据清单 4-13 使用两个函数(“预测输出”和“适应度”)完成的。
def predict_outputs(weights_mat, data_inputs, data_outputs, activation="relu"):
predictions = numpy.zeros(shape=(data_inputs.shape[0]))
for sample_idx in range(data_inputs.shape[0]):
r1 = data_inputs[sample_idx, :]
for curr_weights in weights_mat:
r1 = numpy.matmul(a=r1, b=curr_weights)
if activation == "relu":
r1 = relu(r1)
elif activation == "sigmoid":
r1 = sigmoid(r1)
predicted_label = numpy.where(r1 == numpy.max(r1))[0][0]
predictions[sample_idx] = predicted_label
correct_predictions = numpy.where(predictions == data_outputs)[0].size
accuracy = (correct_predictions/data_outputs.size)*100
return accuracy, predictions
def fitness(weights_mat, data_inputs, data_outputs, activation="relu"):
accuracy = numpy.empty(shape=(weights_mat.shape[0]))
for sol_idx in range(weights_mat.shape[0]):
curr_sol_mat = weights_mat[sol_idx, :]
accuracy[sol_idx], _ = predict_outputs(curr_sol_mat, data_inputs, data_outputs, activation=activation)
return accuracy
Listing 4-13Predicting Class Labels for Calculating Accuracy
“predict_outputs”函数接受单个解决方案的权重、训练数据的输入和输出,以及指定要使用哪个激活函数的可选参数。它类似于清单 4-7 中创建的前一个函数,但是不同之处在于它被调整为返回解的精度。但是它只返回一个解的精度,而不是群体中所有解的精度。“fitness”函数的作用是循环遍历每个解,将其传递给“predict_outputs”函数,将所有解的精度存储到“accuracy”数组中,并最终返回该数组。
计算出每个解决方案的适合度值(即精度)后,图 4-12 中 GA 的剩余步骤以与之前相同的方式应用。最佳亲本根据其准确性被选入交配池。然后应用变异和交叉变异来产生后代。新一代的群体是利用后代和父母两者创建的。这些步骤要重复几代。
完整的 Python 实现
这个项目的 Python 实现有三个 Python 文件:
-
GA.py 用于实现 GA 功能。
-
ANN.py 用于实现 ANN 函数。
-
第三个文件,用于通过若干代调用此类函数。
第三个文件是主文件,因为它连接了所有的函数。它读取特征和类标签文件,基于 STD 值 50 过滤特征,创建 ANN 架构,生成初始解决方案,通过计算所有解决方案的适应度值循环通过若干代,选择最佳父代,应用交叉和变异,并最终创建新群体。它的实现在清单 4-14 中。该文件定义了 GA 参数,例如每个种群的解的数量、选择的父代的数量、突变百分比和代的数量。您可以为它们尝试不同的值。
import numpy
import GA
import pickle
import ANN
import matplotlib.pyplot
f = open("dataset_features.pkl", "rb")
data_inputs2 = pickle.load(f)
f.close()
features_STDs = numpy.std(a=data_inputs2, axis=0)
data_inputs = data_inputs2[:, features_STDs>50]
f = open("outputs.pkl", "rb")
data_outputs = pickle.load(f)
f.close()
#GA parameters:
# Mating Pool Size (Number of Parents)
# Population Size
# Number of Generations
# Mutation Percent
sol_per_pop = 8
num_parents_mating = 4
num_generations = 1000
mutation_percent = 10
#Creating the initial population.
initial_pop_weights = []
for curr_sol in numpy.arange(0, sol_per_pop):
HL1_neurons = 150
input_HL1_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(data_inputs.shape[1], HL1_neurons))
HL2_neurons = 60
HL1_HL2_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(HL1_neurons, HL2_neurons))
output_neurons = 4
HL2_output_weights = numpy.random.uniform(low=-0.1, high=0.1,
size=(HL2_neurons, output_neurons))
initial_pop_weights.append(numpy.array([input_HL1_weights,
HL1_HL2_weights,
HL2_output_weights]))
pop_weights_mat = numpy.array(initial_pop_weights)
pop_weights_vector = GA.mat_to_vector(pop_weights_mat)
best_outputs = []
accuracies = numpy.empty(shape=(num_generations))
for generation in range(num_generations):
print("Generation : ", generation)
# converting the solutions from being vectors to matrices.
pop_weights_mat = GA.vector_to_mat(pop_weights_vector,
pop_weights_mat)
# Measuring the fitness of each chromosome in the population.
fitness = ANN.fitness(pop_weights_mat,
data_inputs,
data_outputs,
activation="sigmoid")
accuracies[generation] = fitness[0]
print("Fitness")
print(fitness)
# Selecting the best parents in the population for mating.
parents = GA.select_mating_pool(pop_weights_vector,
fitness.copy(),
num_parents_mating)
print("Parents")
print(parents)
# Generating next generation using crossover.
offspring_crossover = GA.crossover(parents,
offspring_size=(pop_weights_vector.shape[0]-parents.shape[0], pop_weights_vector.shape[1]))
print("Crossover")
print(offspring_crossover)
# Adding some variations to the offspring using mutation.
offspring_mutation = GA.mutation(offspring_crossover,
mutation_percent=mutation_percent)
print("Mutation")
print(offspring_mutation)
# Creating the new population based on the parents and offspring.
pop_weights_vector[0:parents.shape[0], :] = parents
pop_weights_vector[parents.shape[0]:, :] = offspring_mutation
pop_weights_mat = GA.vector_to_mat(pop_weights_vector, pop_weights_mat)
best_weights = pop_weights_mat [0, :]
acc, predictions = ANN.predict_outputs(best_weights, data_inputs, data_outputs, activation="sigmoid")
print("Accuracy of the best solution is : ", acc)
matplotlib.pyplot.plot(accuracies, linewidth=5, color="black")
matplotlib.pyplot.xlabel("Iteration", fontsize=20)
matplotlib.pyplot.ylabel("Fitness", fontsize=20)
matplotlib.pyplot.xticks(numpy.arange(0, num_generations+1, 100), fontsize=15)
matplotlib.pyplot.yticks(numpy.arange(0, 101, 5), fontsize=15)
f = open("weights_"+str(num_generations)+"_iterations_"+str(mutation_percent)+"%_mutation.pkl", "wb")
pickle.dump(pop_weights_mat, f)
f.close()
Listing 4-14The Main File Connecting GA and ANN Together
基于 1,000 代,使用 Matplotlib 可视化库在该文件的末尾创建了一个图,该图显示了精度在每代之间如何变化。如图 4-13 所示。经过 1000 次迭代,准确率达到 97%以上。相比之下,在没有使用优化技术的情况下,这一比例为 45%。这是关于为什么结果可能不好的证据,不是因为模型或数据有问题,而是因为没有使用优化技术。当然,对参数使用不同的值,比如 10,000 代,可能会提高精度。在这个文件的末尾,它将参数以矩阵的形式保存到磁盘上以备后用。
图 4-13
根据 1,000 次迭代的分类精度演变
GA.py 文件的实现在清单 4-15 中。注意,“mutation”函数接受“mutation_percent”参数,该参数定义了随机改变其值的基因数量。在清单 4-14 的主文件中设置为 10%。该文件包含两个新函数“mat_to_vector”和“vector_to_mat”。
import numpy
import random
# Converting each solution from matrix to vector.
def mat_to_vector(mat_pop_weights):
pop_weights_vector = []
for sol_idx in range(mat_pop_weights.shape[0]):
curr_vector = []
for layer_idx in range(mat_pop_weights.shape[1]):
vector_weights = numpy.reshape(mat_pop_weights[sol_idx, layer_idx], newshape=(mat_pop_weights[sol_idx, layer_idx].size))
curr_vector.extend(vector_weights)
pop_weights_vector.append(curr_vector)
return numpy.array(pop_weights_vector)
# Converting each solution from vector to matrix.
def vector_to_mat(vector_pop_weights, mat_pop_weights):
mat_weights = []
for sol_idx in range(mat_pop_weights.shape[0]):
start = 0
end = 0
for layer_idx in range(mat_pop_weights.shape[1]):
end = end + mat_pop_weights[sol_idx, layer_idx].size
curr_vector = vector_pop_weights[sol_idx, start:end]
mat_layer_weights = numpy.reshape(curr_vector, newshape=(mat_pop_weights[sol_idx, layer_idx].shape))
mat_weights.append(mat_layer_weights)
start = end
return numpy.reshape(mat_weights, newshape=mat_pop_weights.shape)
def select_mating_pool(pop, fitness, num_parents):
# Selecting the best individuals in the current generation as parents for producing the offspring of the next generation.
parents = numpy.empty((num_parents, pop.shape[1]))
for parent_num in range(num_parents):
max_fitness_idx = numpy.where(fitness == numpy.max(fitness))
max_fitness_idx = max_fitness_idx[0][0]
parents[parent_num, :] = pop[max_fitness_idx, :]
fitness[max_fitness_idx] = -99999999999
return parents
def crossover(parents, offspring_size):
offspring = numpy.empty(offspring_size)
# The point at which crossover takes place between two parents. Usually, it is at the center.
crossover_point = numpy.uint8(offspring_size[1]/2)
for k in range(offspring_size[0]):
# Index of the first parent to mate.
parent1_idx = k%parents.shape[0]
# Index of the second parent to mate.
parent2_idx = (k+1)%parents.shape[0]
# The new offspring will have its first half of its genes taken from the first parent.
offspring[k, 0:crossover_point] = parents[parent1_idx, 0:crossover_point]
# The new offspring will have its second half of its genes taken from the second parent.
offspring[k, crossover_point:] = parents[parent2_idx, crossover_point:]
return offspring
def mutation(offspring_crossover, mutation_percent):
num_mutations = numpy.uint8((mutation_percent*offspring_crossover.shape[1])/100)
mutation_indices = numpy.array(random.sample(range(0, offspring_crossover.shape[1]), num_mutations))
# Mutation changes a single gene in each offspring randomly.
for idx in range(offspring_crossover.shape[0]):
# The random value to be added to the gene.
random_value = numpy.random.uniform(-1.0, 1.0, 1)
offspring_crossover[idx, mutation_indices] = offspring_crossover[idx, mutation_indices] + random_value
return offspring_crossover
Listing 4-15GA.py File Holding the Functions of GA
最后,根据清单 4-16 实现 ANN.py。它包含激活函数(sigmoid 和 ReLU)的实现,以及用于计算精度的“适应度”和“预测输出”函数。
import numpy
def sigmoid(inpt):
return 1.0/(1.0+numpy.exp(-1*inpt))
def relu(inpt):
result = inpt
result[inpt<0] = 0
return result
def predict_outputs(weights_mat, data_inputs, data_outputs, activation="relu"):
predictions = numpy.zeros(shape=(data_inputs.shape[0]))
for sample_idx in range(data_inputs.shape[0]):
r1 = data_inputs[sample_idx, :]
for curr_weights in weights_mat:
r1 = numpy.matmul(a=r1, b=curr_weights)
if activation == "relu":
r1 = relu(r1)
elif activation == "sigmoid":
r1 = sigmoid(r1)
predicted_label = numpy.where(r1 == numpy.max(r1))[0][0]
predictions[sample_idx] = predicted_label
correct_predictions = numpy.where(predictions == data_outputs)[0].size
accuracy = (correct_predictions/data_outputs.size)*100
return accuracy, predictions
def fitness(weights_mat, data_inputs, data_outputs, activation="relu"):
accuracy = numpy.empty(shape=(weights_mat.shape[0]))
for sol_idx in range(weights_mat.shape[0]):
curr_sol_mat = weights_mat[sol_idx, :]
accuracy[sol_idx], _ = predict_outputs(curr_sol_mat, data_inputs, data_outputs, activation=activation)
return accuracy
Listing 4-16ANN.py File Implementing the ANN
五、卷积神经网络
先前讨论的人工神经网络的结构被称为 FC 神经网络(FCNNs)。原因是层 i 中的每个神经元都连接到层 i-1 和 i+1 中的所有神经元。两个神经元之间的每个连接都有两个参数:权重和偏差。添加更多的层和神经元会增加参数的数量。因此,即使在多个图形处理单元(GPU)和多个中央处理单元(CPU)上的设备上训练这样的网络也是非常耗时的。在处理和存储能力有限的个人电脑上训练这样的网络变得不可能。
在分析图像等多维数据时,CNN(也称为 ConvNets)比 FC 网络更节省时间和内存。但是为什么呢?在图像分析方面,ConvNets 比 FC 网络有什么优势?ConvNet 是如何从 FC 网络派生出来的?CNN 中卷积这个术语从何而来?这些问题将在本章中得到回答。为了更好地理解一切是如何工作的,本章使用 NumPy 库实现了 CNN,完成了在这些网络中构建不同层所需的所有步骤,包括卷积、池化、激活和 FC。最后,将创建一个名为 NumPyCNN 的项目来帮助轻松创建 CNN,然后在附录 a 中学习如何部署它。
从安到 CNN
ANN 是 CNN 的基础,增加了一些变化,使其适合分析大量数据。即使在分析非常小的图像(例如,150×150 像素的图像)时,将所有神经元连接在一起也会增加参数的数量。这种情况下的输入层将有 22,500 个神经元。将其连接到另一个有 500 个神经元的隐层,需要的参数个数为 22500×500 = 11250000。现实世界的应用可能会处理高维图像,其中最小的维度可能有 1000 个像素或更多。对于大小为 1000×1000 的输入图像和 2000 个神经元的隐藏层,参数的数量等于 20 亿。注意,输入图像是灰色的。
接下来的小节包括以下问题:使用 CNN 而不是 ANN 背后的直觉是什么?我们真的需要传统人工神经网络中使用的所有参数吗?CNN 和 ANN 有什么不同,又是如何从 ANN 衍生出来的?最后,CNN 中使用的卷积这个术语的来源是什么?让我们开始回答这些问题。
DL 背后的直觉
在第一章中,我们处理了特征提取的任务。这是执行图像分析任务的传统方法,包括使用一组代表所解决问题的特征。这可能需要正在研究的领域的专家的帮助,因为一个特征对于给定的问题可能是健壮的,但是对于另一个问题可能是虚弱的。为给定的问题选择最佳特征是一个挑战。从非常多的特征开始,如何将它们减少到最佳最小集合?
当处理少量数据时,我们也许能够找到一组有细微变化的特征。数据中存在的差异越多,就越难找到涵盖所有差异的一组特征。
在传统的分类问题中,目标是找到区分所用类别的最佳特征集。基于 f 1 ()函数计算特征 1 后,图 5-1 中给出了每一类的样本。这个函数在第一个类的左边部分做得很好,但是在同一个类的右边部分做得很差。在这一部分中,两个类别之间存在重叠,因此分类精度非常差。即使是最复杂的 ML 模型也无法拟合这一数据。这是因为很多样本的值几乎与 f 1 ()相同。为了提高分类性能,函数 f 1 ()需要做一些修改。
图 5-1
二类数据分布采用【f】1()。类别 1 样本表示为实心圆,类别 2 样本表示为空心圆。
为了解决这个问题,f 1 ()的结果将被用作另一个函数 f 2 ()的输入。因此,对于输入样本 s 1 ,其最终特征将是一连串函数 f2(f1(s1)的结果。数据分布如图 5-2 所示。看起来结果比上一次增强了很多。与第一种情况相比,重叠的百分比减少了,因为第二类中的一些样本明显远离第一类。尽管如此,这两个类别之间还是有重叠的。我们的目标是分割数据,使每个样本靠近其类中的样本,同时远离另一类中的样本。
图 5-2
数据分配使用f2(f1())
为了增强分类的结果,我们可以使用 f 2 ()的输出作为另一个函数 f 3 ()的输入,这样函数链就是 f3(f2(f1())。根据图 5-3 ,结果优于前两种情况。
图 5-3
数据分布使用f3(f2(f1())
通过以同样的方式工作并使用第四个函数 f 4 (),我们可以找到一个可接受的结果,如图 5-4 所示。此时,我们可以构建一个非常简单的线性分类器来分割数据。我们可能会注意到,在构建了一个健壮的特征函数之后,分类变得非常容易。这与图 5-1 中的不良特征函数相比,后者需要使用非常复杂的分类器。
图 5-4
正确分离数据后的线性分类
前面的讨论总结了 DL 模型的目标,即自动特征转换。目标是创建一个特征变换函数,将数据样本从执行 ML 任务复杂的不良状态变换到任务较简单的另一种状态。
CNN 是这本书的重点,它接受纯图像像素,并自己找到对数据进行正确分类的最佳特征集。CNN 中的每一层都将数据从一种状态转换为另一种状态,以提高性能。人工神经网络的美妙之处在于它是一个通用的函数逼近器,可以逼近任何类型的函数。每个函数都有一组参数,即权重和偏差。一个功能(即层)的输出是另一个功能(即层)的输入。扩展 ANN 体系结构,直到分类性能达到最佳。例如,我们可以将之前讨论的每个步骤与一个隐藏层相关联,这样网络将具有如图 5-5 所示的架构。这就让大家了解了隐藏层的用处,这对于 ANN 的新手来说是个麻烦。
图 5-5
人工神经网络需要通过使用线性分类器来转换用于分类的数据
下一节讨论 CNN 是如何从人工神经网络衍生而来,以及它如何在图像分析中比传统的人工神经网络更有效。
卷积求导
图像分析面临许多挑战,例如分类、对象检测、识别、描述等等。例如,如果要创建一个图像分类器,它应该能够以高精度工作,即使存在诸如遮挡、照明变化、视角等变化。以特征工程为主要步骤的传统图像分类流水线不适合在丰富的环境中工作。即使是该领域的专家也无法给出一个或一组能够在不同变化下达到高精度的特征。从这个问题出发,就产生了特征学习的思想。自动学习适合处理图像的功能。这就是为什么人工神经网络是执行图像分析的最稳健的方法之一。基于诸如 GD 的学习算法,ANN 自动学习图像特征。原始图像被应用到人工神经网络,人工神经网络负责生成描述它的特征。
使用 FC 网络的图像分析
让我们看看人工神经网络是如何处理图像的,以及为什么相对于图 5-6 中的 3×3 灰度图像,CNN 在时间和内存需求方面是高效的。为了简单起见,给出的例子使用了小的图像尺寸和较低数量的神经元。
图 5-6
作为 FCNN 输入的微小图像
ANN 输入层的输入是图像像素。每个像素代表一个输入。因为人工神经网络处理的是 1D 向量,而不是 2D 矩阵,所以最好将之前的 2D 图像转换成 1D 向量,如图 5-7 所示。
图 5-7
2D 图像到 1D 矢量
每个像素映射到向量中的一个元素。向量中的每个元素代表人工神经网络中的一个神经元。因为图像有 3×3=9 个像素,那么输入层会有 9 个神经元。将向量表示为行或列并不重要,但 ANN 通常水平延伸,其每一层都表示为列向量。
准备好人工神经网络的输入后,下一步是添加学习如何将图像像素转换为代表性特征的隐藏层。假设有一个 16 个神经元的单一隐藏层,如图 5-8 。
图 5-8
从单个输入神经元到所有隐含层神经元的连接
因为网络是 FC,这意味着层 i 中的每个神经元都连接到层 i-1 中的所有神经元。因此,隐藏层中的每个神经元都连接到输入层中的所有 9 个像素。换句话说,每个输入像素连接到隐藏层中的 16 个神经元,其中每个连接都有相应的唯一参数。通过将每个像素连接到隐藏层中的所有神经元,对于如图 5-9 所示的微型网络,将有 9×16=144 个参数或权重。
图 5-9
将所有输入神经元连接到所有隐藏层神经元
大量参数
此 FC 网络中的参数数量似乎可以接受。但是随着图像像素和隐藏层数量的增加,这个数字会大大增加。
比如这个网络有两个隐层,分别是 90 和 50 个神经元,那么输入层和第一个隐层之间的参数个数是 9×90=810 。两个隐层之间的参数个数为90×50 = 4500。该网络的参数总数为810+4500 = 5310。对于这样一个网络来说,这是一个很大的数字。另一种情况是尺寸为 32×32(1024 像素)的非常小的图像。如果网络以 500 个神经元的单隐层运行,则总共有1024 * 500 = 512000 个参数(权重)。对于只有一个隐藏层处理小图像的网络来说,这是一个巨大的数字。必须有一个减少参数数量的解决方案。这就是 CNN 发挥关键作用的地方。它创建了一个非常大的网络,但参数比 FC 网络少。
神经元分组
即使对于小的网络,使参数数量变得非常大的问题是 FC 网络在连续层中的每两个神经元之间添加一个参数。如图 5-10 所示,可以将单个参数赋予一个神经元块或一组神经元,而不是在每两个神经元之间分配单个参数。图 5-8 中索引为 0 的像素连接到索引为(0,1,2,3)的前四个神经元,具有四个不同的权重。如果神经元被分成如图 5-10 所示的四个一组,那么同一组中的所有神经元将被分配一个参数。
图 5-10
将每四个隐藏神经元分组以使用相同的权重
因此,图 5-10 中索引为 0 的像素将与图 5-11 中权重相同的前四个神经元相连。相同的参数被分配给每四个连续的神经元。结果,参数的数量减少了四分之一。每个输入神经元将有 16/4=4 个参数。整个网络将有 144/4=36 个参数。参数减少了 75%。这没问题,但是仍然可以减少更多的参数。
图 5-11
同一组中的所有神经元使用相同的权重
因为有四组神经元,这意味着这一层有四个过滤器。因此,该图层的输出将具有等于 3 的第三维,这意味着将返回三个过滤后的图像。CNN 的目标是找到使每个输入图像与其类别标签相关联的这种滤波器的最佳值。
图 5-12 显示了从每个像素到每组第一个神经元的独特连接。也就是说,所有缺失的连接都只是现有连接的副本。假设每个像素到每个组的每个神经元都有一个连接,如图 5-9 ,因为网络还是 FC。
图 5-12
隐藏神经元分组后,输入层和隐藏层之间的唯一连接更少
为了简单起见,除了所有像素与第一组中的第一个神经元之间的连接之外,所有连接都被省略,如图 5-13 所示。似乎每个组仍然连接到所有 9 个像素,因此它将有 9 个参数。可以减少这个神经元连接到的像素的数量。
图 5-13
输入层中所有神经元与隐藏层中第一组神经元之间的连接
像素空间相关性
当前配置使每个神经元接受所有像素。如果有一个函数 f(x1,x2,x3,x4)接受四个输入,这意味着将基于所有这四个输入做出决策。如果只有两个输入的函数给出的结果与使用所有四个输入的结果相同,那么我们不必使用所有这四个输入。给出所需结果的两个输入就足够了。这与前面的情况类似。每个神经元接受所有 9 个像素作为输入。如果使用更少的像素会返回相同或更好的结果,那么我们应该通过它。
通常,在图像分析中,每个像素与其周围的像素(即邻居)高度相关。两个像素之间的距离越大,它们就越不相关。例如,在图 5-14 所示的摄影师图像中,人脸内部的像素与其周围的人脸像素相关。但它与远处像素(如天空或地面)的相关性较小。
图 5-14
摄影师图像
基于这一假设,前面示例中的每个神经元将只接受彼此空间相关的像素,因为对所有像素进行处理是合理的。如图 5-15 所示,可以只选择 4 个空间相关的像素,而不是将所有 9 个像素作为输入应用于每个神经元。图像中位于(0,0)处的列向量中索引为 0 的第一个像素将作为输入应用于具有其 3 个最大空间相关像素的第一个神经元。基于输入图像,与该像素在空间上最相关的 3 个像素是索引为(0,1)、(1,0)和(1,1)的像素。因此,神经元将只接受 4 个像素,而不是 9 个。因为同一组中的所有神经元共享相同的参数,所以每组中的 4 个神经元将只有 4 个参数,而不是 9 个。因此,参数总数将为 4×4=16。与图 5-9 中的 FC 网络相比,减少了 144–16 = 128 个参数(即减少了 88.89%)。
图 5-15
将第一组相关像素连接到第一组
CNN 中的卷积
至此,为什么 CNN 比 FC 网络更节省时间和内存的问题得到了解答。使用较少的参数允许增加具有大量层和神经元的深度 CNN,这在 FC 网络中是不可能的。接下来是得到 CNN 中卷积的思想。
现在只有四个权重分配给同一块中的所有神经元。这四个权重将如何覆盖所有 9 个像素?让我们看看这是如何工作的。
图 5-16 显示了图 5-15 中之前的网络,但在连接中添加了权重标签。在神经元内部,4 个输入像素中的每一个都乘以其相应的权重。该方程如图 5-16 所示。如图 5-16 所示,将四个像素和权重可视化为矩阵会更好。先前的结果将通过逐个元素地将权重矩阵乘以当前的 4 个像素的集合来实现。实际上,卷积掩模的尺寸应该是奇数,例如 3×3。为了便于演示,本例中使用了一个 2×2 的遮罩。
图 5-16
添加每个连接的权重并将它们可视化为矩阵
移动到索引为 1 的下一个神经元,它将与索引为 0 的神经元所使用的具有相同权重的另一组空间相关像素一起工作。此外,指数为 2 和 3 的神经元将与其他两组空间相关的像素一起工作。如图 5-17 所示。似乎组中的第一个神经元从左上角的像素开始,并选择其周围的多个像素。该组中的最后一个神经元处理右下角的像素及其周围的像素。调节中间神经元以选择中间像素。这种行为等同于组的权重集和图像之间的卷积。这就是为什么 CNN 有卷积这个术语。
图 5-17
将每组相关像素及其权重高亮显示为矩阵
同样的程序也适用于其余的神经元群。每组的第一个神经元从左上角及其周围的像素开始。每组的最后一个神经元处理右下角及其周围的像素。中间神经元作用于中间像素。
在理解了 CNN 是如何从 ANN 导出之后,我们可以举一个例子,该例子在输入图像和滤波器(即一组权重)之间执行卷积,并产生其结果。
设计 CNN
在我们将要使用 CNN 设计的例子中,有三种形状:矩形、三角形和圆形。每一个都用一个 4×4 的矩阵来表示,如图 5-18 所示,其中 1 代表白色,0 代表黑色。目标是构建一个 CNN,当有矩形时返回 1,否则返回 0。我们如何做到这一点?
图 5-18
用 4×4 矩阵表示的矩形、三角形和圆形。像素 1 是白色,像素 0 是黑色。
开始设计 CNN 时,第一步是确定层数和每层中的滤波器数量。通常,CNN 不仅仅只有一个卷积(简称 conv)层,但是我们将只使用这一层。这样的问题可以自测一下。
首先,卷积层研究我们正在寻找的形状结构的构建块。所以,你要问自己的第一个问题是,与三角形和圆形相比,矩形有什么特别之处。矩形有四条边,两条垂直边和两条水平边。我们可以从这些信息中受益。但是还要注意,矩形中存在的属性不应该存在于其他形状中。其他形状已经具有不同的属性。其他两个形状都没有两条水平边和两条垂直边。这太棒了。
接下来的问题是如何让卷积层识别出边缘的存在。请记住,CNN 首先识别形状的单个元素,然后将这些元素连接在一起。因此,我们不是寻找四条边,也不是寻找两条平行的垂直边和两条平行的水平边,而是识别任何垂直或水平边。所以,问题变得更具体了。我们如何识别垂直或水平边缘?这可以简单地用渐变来完成。
第一层将有一个寻找水平边缘的过滤器和另一个寻找垂直边缘的过滤器。这些滤波器在图 5-19 中显示为 3×3 矩阵。因此,我们知道有多少过滤器使用在第一 conv 层,也知道这些过滤器是什么。选择 3×3 的大小用于滤波器,因为这是水平和垂直边缘的结构清晰的良好大小。
图 5-19
用于识别大小为 3×3 的水平和垂直边缘的过滤器
在对图 5-19 中的矩阵应用这些过滤器后,conv 层将能够识别图 5-20 中的垂直边缘和图 5-21 中的水平边缘。该层能够识别矩形中的水平和垂直边缘。它还识别三角形底部的水平边缘。但是圆里没有边。目前,CNN 有两个候选矩形,它们是至少有一条边的形状。尽管确信第三个形状不可能是矩形,CNN 必须将它传播到其他层,直到在最后一层做出决定。因为在第一 conv 层中使用了两个滤波器,所以产生了两个输出,每个滤波器一个输出。
图 5-21
黑色的可识别水平边缘
图 5-20
黑色的可识别垂直边缘
下一个卷积层将接受第一个卷积层的结果,并基于它继续。让我们重复第一层问的同样的问题。要使用的过滤器数量是多少,它们的结构是什么?基于矩形结构,我们发现每条水平边都与一条垂直边相连。因为有两条水平边,这需要使用图 5-22 中尺寸为 3×3 的两个过滤器。
图 5-22
用于识别大小为 3×3 的水平和垂直连接边的过滤器
将这些过滤器应用于 conv 层 1 的结果后,第二个 conv 层中使用的过滤器的结果分别如图 5-23 和图 5-24 所示。对于矩形,过滤器能够找到两条所需的边并将它们连接在一起。在三角形中,只有一条水平边,没有垂直边与之相连。因此,三角形没有正输出。
图 5-24
黑色第二层中第二个滤镜的结果
图 5-23
黑色第二层中第一个滤镜的效果
从目前的结果来看,我们还没有识别出那个矩形,但是到目前为止我们已经做得很好了。我们将各个边缘连接到更有意义的结构上。现在,识别完整形状只需一步,即连接图 5-23 和 5-24 中识别出的边。结果如图 5-25 所示。这太棒了。
图 5-25
通过第二 conv 层连接已识别形状的结果
但是我们是手动完成的,不是自动完成的。我们通过告诉 CNN 使用过滤器来引导它。但在常规问题中却不是这样。CNN 会自己找到过滤器。我们只是试图通过使用正确的过滤器来简化事情。记住,这些过滤器和不同层之间的连接权重是由 CNN 自动调整的。因此,找到正确的过滤器意味着找到正确的权重。这将我们现在学到的东西与我们以前得到的东西联系起来。
参数缩减的池化操作
卷积运算只是找到蒙版和与滤镜大小相同的图像部分之间的点积。如果滤波器匹配图像的一部分,那么 SOP 将会很高。假设应用卷积运算的输出如图 5-26 所示。
图 5-26
卷积运算的结果
阴影区域是图像部分和所使用的滤波器之间高度匹配的区域。请注意,这里有两条信息:
-
高分的存在意味着图像中存在感兴趣区域(ROI)。
-
高分的位置告诉图像中在过滤器和图像部分之间出现匹配的位置。
但是我们对这两条信息都感兴趣吗?答案是否定的。我们只是对第二部分感兴趣。这是因为 CNN 的唯一目标是告诉目标物体是否存在于图像中。我们对本地化不感兴趣。
因此,如果我们不关心确切的位置,我们可以避免存储这样的空间信息。例如,我们可以说 ROI 存在于图像中,但是避免存储它的确切位置。如果我们这样做,先前的矩阵大小将会减小,如图 5-27 所示。
图 5-27
卷积运算的结果
我们可以去掉额外的信息,因为它对我们来说并不重要。我们只是保留了匹配发生的信息。这是通过保持卷积输出矩阵的最大值来实现的。找到高分告诉我们有匹配。
但是我们如何减少矩阵的大小呢?例如,这是通过保持每个 2×2 区域的最大值来实现的。这种操作称为最大池化。
通过应用最大池操作,在计算时间和内存需求方面有一个非常重要的改进。它不是在内存中保存一个 4×4 大小的矩阵,而是缩小到一半大小(2×2)。这通过只保留 4 个值而不是 16 个值来节省内存。此外,由于最大池化操作的输出将是另一个卷积操作的输入,因此减少了时间。这个卷积运算将在大小为 2×2 而不是 4×4 的矩阵上工作。
最后,应用最大池操作通过移除在 CNN 中对我们不重要的虚假特征(这是匹配发生的确切位置)来帮助我们减少计算时间和存储器需求。这个操作使得 CNN 平移不变性。
卷积运算示例
本小节举例说明了如何对图 5-28 所示的 2D 图像中 8×8 的样本进行卷积运算。卷积中将使用单个滤波器,即图 5-19 中的水平梯度检测器。卷积的应用方式是将滤波器置于每个像素的中心,将滤波器中的每个元素乘以图像中相应的像素,返回新图像中这些乘积的总和。
图 5-28
要应用卷积运算的大小为 8×8 的图像样本
因为过滤器的大小是 3×3,并且它的每个元素都乘以图像中的一个元素,所以在将过滤器居中在任何像素上之后,必须有一个元素对应于过滤器中的每个元素。很明显,这不适用于图像的边界(即除了顶行和底行之外的最左边和最右边的列),如图 5-28 中的灰色标记。在这种情况下有两种解决方案。第一种是通过用零填充额外的行和列来继续处理像素,或者换句话说,如果过滤器没有相应的图像像素,则将任何元素乘以零。这将产生与原始图像大小相等的输出图像。
在这种情况下,根据等式 5-1 计算在顶部和底部边界填充所需的行数。根据等式 5-2 计算左侧和右侧的填充列数。对于过滤器大小为 3×3 的示例,需要填充两行和两列。
填充 行 = 楼层 ( 过滤 行 /2)(等式 5-1)
填充 列 = 楼层 ( 过滤 列 /2)(等式 5-2)
在大多数情况下,筛选器中的行数和列数是奇数。这有助于定位将要插入 SOP 的中心像素。
第二个解决方案是避免使用图像边框。在这种情况下,结果图像的大小将小于原始图像的大小。输出图像中的行数和列数分别根据等式 5.3 和 5.4 计算。对于大小为 8×8 的输入图像,结果图像的大小为 6×6。
new sizerows=OldSizerows2XP addingrows(方程式 5-3)
新闻 新闻 = 新闻新闻新闻新闻新闻新闻新闻新闻新闻新闻新闻新闻新闻】新闻新闻新闻新闻新闻新闻新闻新闻】******
假设没有使用填充,那么要处理的第一个像素是位于第二行第二列的值为 103 的像素。将滤波器置于该像素的中心,并将每个元素乘以其对应的像素,SOP 如下:
该结果被插入到位于左上行和左上列的像素处的新图像中。计算一个像素的输出后,下一步是移动滤镜以获得另一个像素。所需的移动次数称为步幅。步长为 1 时,过滤器一次移动一列/一行。在当前步骤中,它会将过滤器向右移动一列,并将过滤器置于第二行第三列中值为 70 的像素的中心。跨距 2 一次将滤波器移动两列/行,因此当前像素将是 97。
使用步长 1,我们将从第 2 列到第 7 列开始继续计算第一行中所有像素的 SOP,每次计算 SOP。此后,滤波器向下移动一行,因此当前像素将位于第三行第二列。图 5-29 显示了没有填充和使用步长为 1 的最终结果。
图 5-29
8×8 大小的图像和 3×3 过滤器之间的卷积输出
最大池操作示例
假设有一个 conv 层产生了图 5-29 中的先前结果,并且该层与一个最大池层相连,让我们来计算其输出。
最大池层选择一组像素,通过仅保留它们的最大值来汇总成单个像素。如果使用尺寸为 2×2 的掩模,它将从图 5-29 中用灰色标记的左上角 4 个像素开始。它们的最大值是 94,也就是输出。与卷积类似,最大池将移动蒙版以在另外 4 个像素上工作,因此它需要一个步长。池层的跨距值至少等于 2。原因是跨距为 1 将复制没有输出的值,这是没有帮助的。在图 5-29 中突出显示的黑色像素中,前两列的最大合并操作结果将是 30。使用步长 1 并将掩码向右移动一列,对以黑色突出显示的最后两列执行此操作的结果也是 30。结果,值 30 出现了两次。多次返回同一个值有帮助吗?第一次返回的值 30 意味着卷积滤波器和等于 30 的图像之间存在匹配。所以,我们得到了那个信息。没有必要再重复了。步幅为 1 的工作将使用更多的参数来返回我们不感兴趣的重复结果。因此,步幅为 2 会有所帮助。
对图 5-29 中的卷积结果应用最大汇集运算的结果如图 5-30 所示。
图 5-30
使用大小为 2×2 的掩码的最大池输出
从头开始使用 NumPy 构建 CNN
CNN 是分析图像等多维信号的最先进技术。已经有不同的库实现了 CNN,比如 TensorFlow (TF)和 Keras。这些库将开发人员从一些细节中隔离出来,只给出一个抽象的应用接口(API ),使生活变得更容易,并避免实现中的复杂性。但实际上,这些细节可能会有所不同。有时,数据科学家必须仔细检查这些细节以提高性能。这种情况下的解决方案是自己构建模型的每一部分。这提供了对网络的最高级别的控制。
建议实现这样的模型,以便更好地理解它们。有些想法看起来很清楚,但实际上可能不是这样,直到编程。在了解 CNN 的工作原理后,这样做就很容易了。本节展示了如何使用 NumPy 从头开始实现 CNN。因此,让我们实现它,并将其输出与 TF 进行比较,以验证实现。
在本节中,仅使用 NumPy 库创建了一个 CNN。创建了三层:卷积(简称 conv)、ReLU 和最大/平均池。涉及的主要步骤如下:
-
读取输入图像。
-
准备过滤器。
-
Conv 层:卷积每个滤波器与输入图像。
-
ReLU 图层:在要素地图上应用 ReLU 激活功能(conv 图层的输出)。
-
最大池层:在 ReLU 层的输出上应用池操作。
-
堆叠 conv、ReLU 和 max 池层。
读取输入图像
清单 5-1 从 skimage Python 库中读取一个已经存在的图像,并将其转换成灰色。
import skimage.data
# Reading the image
img = skimage.data.chelsea()
# Converting the image into gray.
img = skimage.color.rgb2gray(img)
Listing 5-1Reading an Image
这个例子使用了 skimage Python 库中已经存在的图像。使用skimage.data.chelsea()
调用图像。注意,这个调用隐式地读取了 skimage 库安装目录中名为“chelsea.png”的图像文件。也可以通过将其路径传递给skimage.data.imread(fname)
来读取图像。例如,如果库位于“Lib\site-packages\skimage\data”中,那么我们可以这样阅读它:
img = skimage.data.chelsea("\AhmedGad\Anaconda3\Lib\site-packages\skimage\data\chelsea.png")
读取图像是第一步,因为接下来的步骤取决于输入尺寸。转换成灰色后的图像如图 5-31 所示。
图 5-31
使用 skimage.data.chelsea()读取原始灰度图像
准备过滤器
下面一行为第一个 conv 层准备滤波器组(简称 l1 ):
l1_filter = numpy.zeros((2,3,3))
根据过滤器的数量和每个过滤器的大小创建零数组。创建两个大小为 3×3 的过滤器;这就是为什么零数组的大小为(2=数量 _ 过滤器,3=数量 _ 行 _ 过滤器,3=数量 _ 列 _ 过滤器)。滤波器的大小被选择为没有深度的 2D 阵列,因为输入图像是灰色的并且没有深度(即 2D)。如果图像是具有三个通道的 RGB,则滤镜大小必须为(3,3,3=深度)。
滤波器组的大小由前面的零数组指定,而不是由滤波器的实际值指定。可以覆盖以下值来检测垂直和水平边缘。
l1_filter[0, :, :] = numpy.array([[[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1]]])
l1_filter[1, :, :] = numpy.array([[[1, 1, 1],
[0, 0, 0],
[-1, -1, -1]]])
Conv 层
准备好滤波器后,下一步是用它们对输入图像进行卷积。下一行使用名为 conv 的函数将图像与滤波器组进行卷积:
l1_feature_map = conv(img, l1_filter)
这个函数只接受两个参数,图像和滤波器组,如清单 5-2 所示。
def conv(img, conv_filter):
if len(img.shape) > 2 or len(conv_filter.shape) > 3: # Check if number of image channels matches the filter depth.
if img.shape[-1] != conv_filter.shape[-1]:
print("Error: Number of channels in both image and filter must match.")
sys.exit()
if conv_filter.shape[1] != conv_filter.shape[2]:
print('Error: Filter must be a square matrix, i.e., number of rows and columns must match.')
sys.exit()
if conv_filter.shape[1]%2==0: # Check if filter dimensions are odd.
print('Error: Filter must have an odd size, i.e., number of rows and columns must be odd.')
sys.exit()
# An empty feature map to hold the output of convolving the filter(s) with the image.
feature_maps = numpy.zeros((img.shape[0]-conv_filter.shape[1]+1,
img.shape[1]-conv_filter.shape[1]+1,
conv_filter.shape[0]))
# Convolving the image by the filter(s).
for filter_num in range(conv_filter.shape[0]):
print("Filter ", filter_num + 1)
curr_filter = conv_filter[filter_num, :] # getting a filter from the bank.
# Checking if there are multiple channels for the single filter.
# If so, then each channel will convolve the image.
# The result of all convolutions is summed to return a single feature map.
if len(curr_filter.shape) > 2:
conv_map = conv_(img[:, :, 0], curr_filter[:, :, 0]) # Array holding the sum of all feature maps.
for ch_num in range(1, curr_filter.shape[-1]): # Convolving each channel with the image and summing the results.
conv_map = conv_map + conv_(img[:, :, ch_num],
curr_filter[:, :, ch_num])
else: # There is just a single channel in the filter.
conv_map = conv_(img, curr_filter)
feature_maps[:, :, filter_num] = conv_map # Holding feature map with the current filter.
return feature_maps # Returning all feature maps.
Listing 5-2Convolving the Image by a Single Filter
该功能首先确保每个滤镜的深度等于图像通道的数量。在下面的代码中,外部的if
检查通道和过滤器是否有深度。如果深度已经存在,那么内部的if
检查它们的不相等。如果不匹配,那么脚本将退出。
if len(img.shape) > 2 or len(conv_filter.shape) > 3: # Check if number of image channels matches the filter depth.
if img.shape[-1] != conv_filter.shape[-1]:
print("Error: Number of channels in both image and filter must match.")
sys.exit()
此外,过滤器的大小应该是奇数,并且过滤器尺寸应该相等(即,行数和列数是奇数并且相等)。如果阻塞,则根据以下两个进行检查。如果这些条件不满足,脚本将退出。
if conv_filter.shape[1] != conv_filter.shape[2]: # Check if filter dimensions are equal.
print('Error: Filter must be a square matrix, i.e., number of rows and columns must match.')
sys.exit()
if conv_filter.shape[1]%2==0:
print('Error: Filter must have an odd size, i.e., number of rows and columns must be odd.')
sys.exit()
不满足上述条件中的任何一个都证明了滤波器深度适合图像,并且卷积准备好被应用。通过滤波器对图像进行卷积,首先初始化一个数组,通过根据以下代码指定其大小来保存卷积的输出(即特征图):
# An empty feature map to hold the output of convolving the filter(s) with the image.
feature_maps = numpy.zeros((img.shape[0]-conv_filter.shape[1]+1,
img.shape[1]-conv_filter.shape[1]+1,
conv_filter.shape[0]))
因为没有跨距或填充,所以特征映射大小将等于(img_rows-filter_rows+1,image_columns-filter_columns+1,num_filters ),如前面的代码所示。注意,组中的每个滤波器都有一个输出特征映射。这就是为什么使用滤波器组中的滤波器数量(conv _ 滤波器.形状[0] )来指定大小作为第三个参数。准备好卷积运算的输入和输出后,下一步是根据清单 5-3 应用它。
# Convolving the image by the filter(s).
for filter_num in range(conv_filter.shape[0]):
print("Filter ", filter_num + 1)
curr_filter = conv_filter[filter_num, :] # getting a filter from the bank.
# Checking if there are multiple channels for the single filter.
# If so, then each channel will convolve the image.
# The result of all convolutions is summed to return a single feature map.
if len(curr_filter.shape) > 2:
conv_map = conv_(img[:, :, 0], curr_filter[:, :, 0]) # Array holding the sum of all feature maps.
for ch_num in range(1, curr_filter.shape[-1]): # Convolving each channel with the image and summing the results.
conv_map = conv_map + conv_(img[:, :, ch_num],
curr_filter[:, :, ch_num])
else: # There is just a single channel in the filter.
conv_map = conv_(img, curr_filter)
feature_maps[:, :, filter_num] = conv_map # Holding feature map with the current filter.
return feature_maps # Returning all feature maps.
Listing 5-3Convolving the Image by Filters
外部循环对滤波器组中的每个滤波器进行迭代,并根据以下代码行返回它以进行进一步的步骤:
curr_filter = conv_filter[filter_num, :] # getting a filter from the bank.
如果要卷积的图像有多个通道,则滤波器的深度必须等于通道数。在这种情况下,卷积是通过将每个图像通道与其在滤波器中的对应通道进行卷积来完成的。最后,结果的总和将是输出特征图。如果图像只有一个通道,那么卷积将是简单的。确定该行为是在if-else
块中完成的:
if len(curr_filter.shape) > 2:
conv_map = conv_(img[:, :, 0], curr_filter[:, :, 0]) # Array holding the sum of all feature map
for ch_num in range(1, curr_filter.shape[-1]): # Convolving each channel with the image and summing the results.
conv_map = conv_map + conv_(img[:, :, ch_num],
curr_filter[:, :, ch_num])
else: # There is just a single channel in the filter.
conv_map = conv_(img, curr_filter)
你可能会注意到,卷积是由一个名为 conv_,的函数应用的,它不同于 conv 函数。函数 conv 只接受输入图像和滤波器组,但不应用自己的卷积。它只是将每组输入滤波器对传递给 conv_ 函数进行卷积。这只是为了使代码更容易研究。清单 5-4 给出了 conv_ 函数的实现。
def conv_(img, conv_filter):
filter_size = conv_filter.shape[1]
result = numpy.zeros((img.shape))
#Looping through the image to apply the convolution operation.
for r in numpy.uint16(numpy.arange(filter_size/2.0,
img.shape[0]-filter_size/2.0+1)):
for c in numpy.uint16(numpy.arange(filter_size/2.0,
img.shape[1]-filter_size/2.0+1)):
# Getting the current region to get multiplied with the filter.
# How to loop through the image and get the region based on
# the image and filer sizes is the most tricky part of convolution.
curr_region = img[r-numpy.uint16(numpy.floor(filter_size/2.0)):r+numpy.uint16(numpy.ceil(filter_size/2.0)),
c-numpy.uint16(numpy.floor(filter_size/2.0)):c+numpy.uint16(numpy.ceil(filter_size/2.0))]
#Element-wise multiplication between the current region and the filter.
curr_result = curr_region * conv_filter
conv_sum = numpy.sum(curr_result) #Summing the result of multiplication.
result[r, c] = conv_sum #Saving the summation in the convolution layer feature map.
#Clipping the outliers of the result matrix.
final_result = result[numpy.uint16(filter_size/2.0):result.shape[0]-numpy.uint16(filter_size/2.0),
numpy.uint16(filter_size/2.0):result.shape[1]-numpy.uint16(filter_size/2.0)]
return final_result
Listing 5-4Convolving the Image by All Filters
它对图像进行迭代,并根据以下代码行提取与过滤器大小相等的区域:
curr_region = img[r-numpy.uint16(numpy.floor(filter_size/2.0)):r+numpy.uint16(numpy.ceil(filter_size/2.0)),
c-numpy.uint16(numpy.floor(filter_size/2.0)):c+numpy.uint16(numpy.ceil(filter_size/2.0))]
然后,它在区域和过滤器之间应用元素级乘法,并对它们求和,以获得作为输出的单个值,如下所示:
#Element-wise multiplication between the current region and the filter.
curr_result = curr_region * conv_filter
conv_sum = numpy.sum(curr_result)
result[r, c] = conv_sum
在通过输入对每个滤波器进行卷积之后,特征图由 conv 函数返回。图 5-32 显示了该 conv 图层返回的特征地图。在本章的最后,清单 5-9 显示了代码中讨论的所有层的结果。
图 5-32
第一个 conv 图层的输出要素地图
这种层的输出将被应用到 ReLU 层。
图层继电器
ReLU 图层对 conv 图层返回的每个要素地图应用 ReLU 激活函数。根据以下代码行,使用 relu 函数调用它:
l1_feature_map_relu = relu(l1_feature_map)
清单 5-5 中实现了 relu 功能。
def relu(feature_map):
#Preparing the output of the ReLU activation function.
relu_out = numpy.zeros(feature_map.shape)
for map_num in range(feature_map.shape[-1]):
for r in numpy.arange(0,feature_map.shape[0]):
for c in numpy.arange(0, feature_map.shape[1]):
relu_out[r, c, map_num] = numpy.max([feature_map[r, c, map_num], 0])
return relu_out
Listing 5-5ReLU Implementation
这很简单。只需遍历特征映射中的每个元素,如果大于 0,则返回特征映射中的原始值。否则,返回 0。ReLU 层的输出如图 5-33 所示。
图 5-33
ReLU 层输出应用于第一个 conv 层的输出
ReLU 层的输出被应用到 max pooling 层。
最大池层
最大池层接受 ReLU 层的输出,并根据以下代码行应用最大池操作:
l1_feature_map_relu_pool = pooling(l1_feature_map_relu, 2, 2)
根据清单 5-6 使用池函数实现。
def pooling(feature_map, size=2, stride=2):
#Preparing the output of the pooling operation.
pool_out = numpy.zeros((numpy.uint16((feature_map.shape[0]-size+1)/stride+1), numpy.uint16((feature_map.shape[1]-size+1)/stride+1), feature_map.shape[-1]))
for map_num in range(feature_map.shape[-1]):
r2 = 0
for r in numpy.arange(0,feature_map.shape[0]-size+1, stride):
c2 = 0
for c in numpy.arange(0, feature_map.shape[1]-size+1, stride):
pool_out[r2, c2, map_num] = numpy.max([feature_map[r:r+size, c:c+size]])
c2 = c2 + 1
r2 = r2 +1
return pool_out
Listing 5-6Max Pooling Implementation
该函数接受三个输入:ReLU 层的输出、池遮罩大小和步幅。和前面一样,它只是创建一个空数组来保存层的输出。数组的大小是根据 size 和 stride 参数指定的,如下行所示:
pool_out = numpy.zeros((numpy.uint16((feature_map.shape[0]-size+1)/stride+1),
numpy.uint16((feature_map.shape[1]-size+1)/stride+1),
feature_map.shape[-1]))
然后它根据外部循环逐个通道地循环通过输入通道,外部循环使用循环变量 map_num 。对于输入中的每个通道,应用最大池操作。根据所使用的步幅和大小,该区域被剪裁,并且它的最大值根据以下行返回到输出数组中:
pool_out[r2, c2, map_num] = numpy.max(feature_map[r:r+size, c:c+size])
汇集层的输出如图 5-34 所示。请注意,池层输出的大小小于其输入,即使它们在图表中看起来相同。
图 5-34
应用于第一个 ReLU 层输出的池层输出
堆叠层
至此,具有 conv、ReLU 和 max 池层的 CNN 架构已经完成。除了前面的层之外,可能还有一些其他的层要堆叠,如清单 5-7 所示。
# Second conv layer
l2_filter = numpy.random.rand(3, 5, 5, l1_feature_map_relu_pool.shape[-1])
print("\n**Working with conv layer 2**")
l2_feature_map = conv(l1_feature_map_relu_pool, l2_filter)
print("\n**ReLU**")
l2_feature_map_relu = relu(l2_feature_map)
print("\n**Pooling**")
l2_feature_map_relu_pool = pooling(l2_feature_map_relu, 2, 2)
print("**End of conv layer 2**\n")
Listing 5-7
Building CNN Architecture
之前的 conv 层使用三个滤镜,它们的值是随机生成的。这就是为什么 conv 图层会产生三个要素地图的原因。这对于连续的 ReLU 和 pooling 层也是一样的。各层的输出如图 5-35 所示。
图 5-35
第二 conv-再-混合层的输出
根据清单 5-8 ,通过添加额外的 conv、ReLU 和池层来扩展 CNN 架构。图 5-36 显示了这些层的输出。conv 层只接受一个过滤器。这就是为什么只有一个要素地图作为输出。
# Third conv layer
l3_filter = numpy.random.rand(1, 7, 7, l2_feature_map_relu_pool.shape[-1])
print("\n**Working with conv layer 3**")
l3_feature_map = conv(l2_feature_map_relu_pool, l3_filter)
print("\n**ReLU**")
l3_feature_map_relu = relu(l3_feature_map)
print("\n**Pooling**")
l3_feature_map_relu_pool = pooling(l3_feature_map_relu, 2, 2)
print("**End of conv layer 3**\n")
Listing 5-8Continue Building CNN Architecture
图 5-36
第三 conv-雷鲁联营层的输出
但是要记住,每一层的输出都是下一层的输入。例如,这些行接受以前的输出作为它们的输入。
l2_feature_map = conv(l1_feature_map_relu_pool, l2_filter)
l3_feature_map = conv(l2_feature_map_relu_pool, l3_filter)
完全码
给出的代码讨论并给出了一个实现 CNN 的例子,并可视化了每一层的结果。代码包含使用 Matplotlib 库的每一层输出的可视化。这个项目的完整代码可以在 GitHub ( https://github.com/ahmedfgad/NumPyCNN
)获得。
import skimage.data
import numpy
import matplotlib
import sys
def conv_(img, conv_filter):
filter_size = conv_filter.shape[1]
result = numpy.zeros((img.shape))
#Looping through the image to apply the convolution operation.
for r in numpy.uint16(numpy.arange(filter_size/2.0,
img.shape[0]-filter_size/2.0+1)):
for c in numpy.uint16(numpy.arange(filter_size/2.0,
img.shape[1]-filter_size/2.0+1)):
# Getting the current region to get multiplied with the filter.
# How to loop through the image and get the region based on
# the image and filer sizes is the most tricky part of convolution.
curr_region = img[r-numpy.uint16(numpy.floor(filter_size/2.0)):r+numpy.uint16(numpy.ceil(filter_size/2.0)),
c-numpy.uint16(numpy.floor(filter_size/2.0)):c+numpy.uint16(numpy.ceil(filter_size/2.0))]
#Element-wise multiplication between the current region and the filter.
curr_result = curr_region * conv_filter
conv_sum = numpy.sum(curr_result) #Summing the result of multiplication.
result[r, c] = conv_sum #Saving the summation in the convolution layer feature map.
#Clipping the outliers of the result matrix.
final_result = result[numpy.uint16(filter_size/2.0):result.shape[0]-numpy.uint16(filter_size/2.0), numpy.uint16(filter_size/2.0):result.shape[1]-numpy.uint16(filter_size/2.0)]
return final_result
def conv(img, conv_filter):
if len(img.shape) > 2 or len(conv_filter.shape) > 3:
if img.shape[-1] != conv_filter.shape[-1]:
print("Error: Number of channels in both image and filter must match.")
sys.exit()
if conv_filter.shape[1] != conv_filter.shape[2]:
print('Error: Filter must be a square matrix, i.e., number of rows and columns must match.')
sys.exit()
if conv_filter.shape[1]%2==0:
print('Error: Filter must have an odd size, i.e., number of rows and columns must be odd.')
sys.exit()
# An empty feature map to hold the output of convolving the filter(s) with the image.
feature_maps = numpy.zeros((img.shape[0]-conv_filter.shape[1]+1,
img.shape[1]-conv_filter.shape[1]+1,
conv_filter.shape[0]))
# Convolving the image by the filter(s).
for filter_num in range(conv_filter.shape[0]):
print("Filter ", filter_num + 1)
curr_filter = conv_filter[filter_num, :] # getting a filter from the bank.
# Checking if there are multiple channels for the single filter.
# If so, then each channel will convolve the image.
# The result of all convolutions is summed to return a single feature map.
if len(curr_filter.shape) > 2:
conv_map = conv_(img[:, :, 0], curr_filter[:, :, 0]) # Array holding the sum of all feature maps.
for ch_num in range(1, curr_filter.shape[-1]): # Convolving each channel with the image and summing the results.
conv_map = conv_map + conv_(img[:, :, ch_num],
curr_filter[:, :, ch_num])
else: # There is just a single channel in the filter.
conv_map = conv_(img, curr_filter)
feature_maps[:, :, filter_num] = conv_map # Holding feature map with the current filter.
return feature_maps # Returning all feature maps.
def pooling(feature_map, size=2, stride=2):
#Preparing the output of the pooling operation.
pool_out = numpy.zeros((numpy.uint16((feature_map.shape[0]-size+1)/stride+1), numpy.uint16((feature_map.shape[1]-size+1)/stride+1), feature_map.shape[-1]))
for map_num in range(feature_map.shape[-1]):
r2 = 0
for r in numpy.arange(0,feature_map.shape[0]-size+1, stride):
c2 = 0
for c in numpy.arange(0, feature_map.shape[1]-size+1, stride):
pool_out[r2, c2, map_num] = numpy.max([feature_map[r:r+size, c:c+size]])
c2 = c2 + 1
r2 = r2 +1
return pool_out
def relu(feature_map):
#Preparing the output of the ReLU activation function.
relu_out = numpy.zeros(feature_map.shape)
for map_num in range(feature_map.shape[-1]):
for r in numpy.arange(0,feature_map.shape[0]):
for c in numpy.arange(0, feature_map.shape[1]):
relu_out[r, c, map_num] = numpy.max([feature_map[r, c, map_num], 0])
return relu_out
# Reading the image
#img = skimage.io.imread("fruits2.png")
img = skimage.data.chelsea()
# Converting the image into gray.
img = skimage.color.rgb2gray(img)
# First conv layer
#l1_filter = numpy.random.rand(2,7,7)*20 # Preparing the filters randomly.
l1_filter = numpy.zeros((2,3,3))
l1_filter[0, :, :] = numpy.array([[[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1]]])
l1_filter[1, :, :] = numpy.array([[[1, 1, 1],
[0, 0, 0],
[-1, -1, -1]]])
print("\n**Working with conv layer 1**")
l1_feature_map = conv(img, l1_filter)
print("\n**ReLU**")
l1_feature_map_relu = relu(l1_feature_map)
print("\n**Pooling**")
l1_feature_map_relu_pool = pooling(l1_feature_map_relu, 2, 2)
print("**End of conv layer 1**\n")
# Second conv layer
l2_filter = numpy.random.rand(3, 5, 5, l1_feature_map_relu_pool.shape[-1])
print("\n**Working with conv layer 2**")
l2_feature_map = conv(l1_feature_map_relu_pool, l2_filter)
print("\n**ReLU**")
l2_feature_map_relu = relu(l2_feature_map)
print("\n**Pooling**")
l2_feature_map_relu_pool = pooling(l2_feature_map_relu, 2, 2)
print("**End of conv layer 2**\n")
# Third conv layer
l3_filter = numpy.random.rand(1, 7, 7, l2_feature_map_relu_pool.shape[-1])
print("\n**Working with conv layer 3**")
l3_feature_map = conv(l2_feature_map_relu_pool, l3_filter)
print("\n**ReLU**")
l3_feature_map_relu = relu(l3_feature_map)
print("\n**Pooling**")
l3_feature_map_relu_pool = pooling(l3_feature_map_relu, 2, 2)
print("**End of conv layer 3**\n")
# Graphing results
fig0, ax0 = matplotlib.pyplot.subplots(nrows=1, ncols=1)
ax0.imshow(img).set_cmap("gray")
ax0.set_title("Input Image")
ax0.get_xaxis().set_ticks([])
ax0.get_yaxis().set_ticks([])
matplotlib.pyplot.savefig("in_img.png", bbox_inches="tight")
matplotlib.pyplot.close(fig0)
# Layer 1
fig1, ax1 = matplotlib.pyplot.subplots(nrows=3, ncols=2)
ax1[0, 0].imshow(l1_feature_map[:, :, 0]).set_cmap("gray")
ax1[0, 0].get_xaxis().set_ticks([])
ax1[0, 0].get_yaxis().set_ticks([])
ax1[0, 0].set_title("L1-Map1")
ax1[0, 1].imshow(l1_feature_map[:, :, 1]).set_cmap("gray")
ax1[0, 1].get_xaxis().set_ticks([])
ax1[0, 1].get_yaxis().set_ticks([])
ax1[0, 1].set_title("L1-Map2")
ax1[1, 0].imshow(l1_feature_map_relu[:, :, 0]).set_cmap("gray")
ax1[1, 0].get_xaxis().set_ticks([])
ax1[1, 0].get_yaxis().set_ticks([])
ax1[1, 0].set_title("L1-Map1ReLU")
ax1[1, 1].imshow(l1_feature_map_relu[:, :, 1]).set_cmap("gray")
ax1[1, 1].get_xaxis().set_ticks([])
ax1[1, 1].get_yaxis().set_ticks([])
ax1[1, 1].set_title("L1-Map2ReLU")
ax1[2, 0].imshow(l1_feature_map_relu_pool[:, :, 0]).set_cmap("gray")
ax1[2, 0].get_xaxis().set_ticks([])
ax1[2, 0].get_yaxis().set_ticks([])
ax1[2, 0].set_title("L1-Map1ReLUPool")
ax1[2, 1].imshow(l1_feature_map_relu_pool[:, :, 1]).set_cmap("gray")
ax1[2, 0].get_xaxis().set_ticks([])
ax1[2, 0].get_yaxis().set_ticks([])
ax1[2, 1].set_title("L1-Map2ReLUPool")
matplotlib.pyplot.savefig("L1.png", bbox_inches="tight")
matplotlib.pyplot.close(fig1)
# Layer 2
fig2, ax2 = matplotlib.pyplot.subplots(nrows=3, ncols=3)
ax2[0, 0].imshow(l2_feature_map[:, :, 0]).set_cmap("gray")
ax2[0, 0].get_xaxis().set_ticks([])
ax2[0, 0].get_yaxis().set_ticks([])
ax2[0, 0].set_title("L2-Map1")
ax2[0, 1].imshow(l2_feature_map[:, :, 1]).set_cmap("gray")
ax2[0, 1].get_xaxis().set_ticks([])
ax2[0, 1].get_yaxis().set_ticks([])
ax2[0, 1].set_title("L2-Map2")
ax2[0, 2].imshow(l2_feature_map[:, :, 2]).set_cmap("gray")
ax2[0, 2].get_xaxis().set_ticks([])
ax2[0, 2].get_yaxis().set_ticks([])
ax2[0, 2].set_title("L2-Map3")
ax2[1, 0].imshow(l2_feature_map_relu[:, :, 0]).set_cmap("gray")
ax2[1, 0].get_xaxis().set_ticks([])
ax2[1, 0].get_yaxis().set_ticks([])
ax2[1, 0].set_title("L2-Map1ReLU")
ax2[1, 1].imshow(l2_feature_map_relu[:, :, 1]).set_cmap("gray")
ax2[1, 1].get_xaxis().set_ticks([])
ax2[1, 1].get_yaxis().set_ticks([])
ax2[1, 1].set_title("L2-Map2ReLU")
ax2[1, 2].imshow(l2_feature_map_relu[:, :, 2]).set_cmap("gray")
ax2[1, 2].get_xaxis().set_ticks([])
ax2[1, 2].get_yaxis().set_ticks([])
ax2[1, 2].set_title("L2-Map3ReLU")
ax2[2, 0].imshow(l2_feature_map_relu_pool[:, :, 0]).set_cmap("gray")
ax2[2, 0].get_xaxis().set_ticks([])
ax2[2, 0].get_yaxis().set_ticks([])
ax2[2, 0].set_title("L2-Map1ReLUPool")
ax2[2, 1].imshow(l2_feature_map_relu_pool[:, :, 1]).set_cmap("gray")
ax2[2, 1].get_xaxis().set_ticks([])
ax2[2, 1].get_yaxis().set_ticks([])
ax2[2, 1].set_title("L2-Map2ReLUPool")
ax2[2, 2].imshow(l2_feature_map_relu_pool[:, :, 2]).set_cmap("gray")
ax2[2, 2].get_xaxis().set_ticks([])
ax2[2, 2].get_yaxis().set_ticks([])
ax2[2, 2].set_title("L2-Map3ReLUPool")
matplotlib.pyplot.savefig("L2.png", bbox_inches="tight")
matplotlib.pyplot.close(fig2)
# Layer 3
fig3, ax3 = matplotlib.pyplot.subplots(nrows=1, ncols=3)
ax3[0].imshow(l3_feature_map[:, :, 0]).set_cmap("gray")
ax3[0].get_xaxis().set_ticks([])
ax3[0].get_yaxis().set_ticks([])
ax3[0].set_title("L3-Map1")
ax3[1].imshow(l3_feature_map_relu[:, :, 0]).set_cmap("gray")
ax3[1].get_xaxis().set_ticks([])
ax3[1].get_yaxis().set_ticks([])
ax3[1].set_title("L3-Map1ReLU")
ax3[2].imshow(l3_feature_map_relu_pool[:, :, 0]).set_cmap("gray")
ax3[2].get_xaxis().set_ticks([])
ax3[2].get_yaxis().set_ticks([])
ax3[2].set_title("L3-Map1ReLUPool")
Listing 5-9Complete Code for Implementing CNN
CNN 中有更多可用的层,很容易将它们添加到前面的层中。例如,可以通过删除最后一层中一定百分比的神经元来实现删除层。FC 层只是将最后一层的结果转换为 1D 向量。
现在这一章已经完成了,希望你对 CNN 有很好的背景知识。
六、TensorFlow 识别应用
像我们所做的那样,使用 NumPy 从头开始构建一个 DL 模型,如 CNN,有助于我们更好地理解每一层的详细工作原理。对于实际应用,不建议使用这样的实现。一个原因是它的计算是计算密集型的,需要努力优化代码。另一个原因是它不支持分布式处理、GPU 和许多其他功能。另一方面,已经有不同的库以省时的方式支持这些特性。这些库包括 TF、Keras、Theano、PyTorch、Caffe 等等。
本章从介绍 TF DL 库开始,通过构建和可视化一个简单线性模型和一个使用 ANN 的两类分类器的计算图。使用 TensorBoard (TB)可视化计算图形。使用 TF-Layers API,创建了一个 CNN 模型,以应用前面讨论的概念来识别来自 CIFAR10 数据集的图像。
TF 简介
构建软件程序有不同的编程范例或风格。它们包括 sequential,它将程序构建为一组顺序行,程序从头到尾都遵循这些顺序行;functional,将代码组织成一组可以多次调用的函数;命令式,告诉计算机程序如何工作的每一个细节步骤;还有更多。一种编程语言可能支持不同的范例。但是这些范例的缺点是依赖于书写的语言。
另一个范例是数据流。数据流语言将其程序表示为文本指令,描述从接收数据到返回结果的计算步骤。一个数据流程序可以被想象成一个图形,除了显示操作的输入和输出之外,还显示操作。数据流语言支持并行处理,因为推导出可以同时执行的独立操作要容易得多。
“TensorFlow”这个名字由两个词组成。第一个是“张量”,它是 TF 在计算中使用的数据单位。第二个词是“流”,反映了它使用数据流范式。因此,TF 构建了一个计算图,它由表示为张量的数据和应用于它们的运算组成。为了让事情更容易理解,只要记住 TF 使用张量和运算,而不是使用变量和方法。
以下是将数据流与 TF 一起使用的一些优势:
-
并行:更容易识别可以并行执行的操作。
-
分布式执行:TF 程序可以跨多个设备(CPU、GPU、TF 处理单元[TPUs])进行分区。TF 本身处理设备间通信和协作的必要工作。
-
可移植性:数据流图是模型代码的独立于语言的表示。数据流图可以使用 Python 创建,保存,然后在 C++程序中恢复。
TF 提供多个 APIs 每种都支持不同级别的控制。最底层的 API 被称为 TF Core,它使程序员能够控制每一段代码,并对创建的模型有更好的控制。
但是 TF 中也有许多更高级别的 API,它们通过为经常使用的任务提供一个简单的接口来使事情变得更容易,例如估计器、TF-Layers 和 TF-Learn。所有更高级别的 API 都构建在 TF Core 之上。例如,TF Estimators 是 TF 中的一个高级 API,它比 TF Core 更容易创建模型。
张量
张量是 TF 中的基本数据单位;它类似于 NumPy 中的数组。张量由一组原始数据类型组成,如整型、浮点型、字符型和字符串型,它们形成一个数组。
张量既有秩又有形状。表 6-1 给出了一些张量的例子,显示了它们的等级和形状。
表 6-1
TF 张量的秩和形状
|张量
|
军阶
|
形状
|
| --- | --- | --- |
| five | Zero | () |
| [4, 8] | one | (2) |
| [[3, 1, 7], [1, 5, 2]] | Two | (2,2) |
| [[[8, 3]], [[11, 9]]]] | Two | (2,1,2) |
张量的秩是维数。张量形状类似于 NumPy 数组形状。NumPy 数组形状返回每个维度中元素的数量,这就是张量形状的工作方式。但是 tensor rank 返回的只是维数,这类似于 NumPy 数组的 ndim 属性。张量秩只是表示张量中维数的标量值,而形状是表示二维数组的元组,如(4,3),其中这些维的大小分别是 4 和 3。
让我们从 TF Core 开始。
TF 核心
为了创建 TF 核心程序,有两个步骤:
-
构建计算图。
-
运行计算图。
TF 使用数据流图来表示程序中的计算。在指定了计算序列之后,它在本地或远程机器上的 TF 会话中被执行。假设图 6-1 表示一个有四个操作 A、B、C 和 D 的图,其中输入被输入到操作 A,然后传播到操作 D。该图可以只执行它的选定部分,而不需要运行整个图。例如,通过指定会话执行的目标是操作 C,那么程序将一直运行,直到只到达操作 C 的结果。这种方式不会执行操作 D。同样,如果操作 B 是目标,那么操作 C 和 D 也不会执行。
图 6-1
有四种运算的图
使用 TF Core API 需要理解数据流图和会话是如何工作的。使用高级 API(如评估器)对用户隐藏了一些开销。但是理解图和会话是如何工作的有助于理解这些高级 API 是如何实现的。
数据流图
数据流图由节点和边组成。节点代表操作单元。边表示操作节点的输入和输出。例如,tensorflow.matmul()方法接受两个输入张量,将它们相乘,然后返回一个输出张量。操作本身用连接到两条边的单个节点表示,每条边对应一个输入张量。还有一条边代表输出张量。稍后,我们将看到如何使用 TB 构建计算图。
一种特殊的节点是常数,它接受零个张量作为输入。常量节点返回的输出是内部存储的值。清单 6-1 创建一个 float32 类型的常量节点并打印出来。
import tensorflow
tensor1 = tensorflow.constant(3.7, dtype=tensorflow.float32)
print(tensor1)
Listing 6-1
Constant Node
当打印常量节点时,结果是
Tensor("Const:0", shape=(), dtype=float32)
根据print
语句的输出,有三点需要注意:
-
形状是(),这意味着张量的秩为 0。
-
输出张量有一个等于“Const:0”的字符串。这个字符串是张量的名字。张量名称是一个重要的属性,因为它用于从图中检索张量值。也是 TF 图中打印的标签。常数张量的默认名称是“Const”。附加到该字符串的 0 将其定义为返回的第一个输出。有些操作会返回多个输出。第一个输出被赋予 0,第二个输出被赋予 1,依此类推。
-
print
语句不打印值 3.7,而是打印节点本身。只有在对节点求值后,才会打印该值。
张量名称
图中可能有多个常数张量。因此,TF 在字符串“Const
”后附加一个数字,该数字在图中的所有常数中标识该常数。清单 6-2 给出了三个常量的例子并打印出来。
import tensorflow
tensor1 = tensorflow.constant(value=3.7, dtype=tensorflow.float32)
tensor2 = tensorflow.constant(value=[[0.5], [7]], dtype=tensorflow.float32)
tensor3 = tensorflow.constant(value=[[12, 9]], dtype=tensorflow.float32)
print(tensor1)
print(tensor2)
print(tensor3)
Listing 6-2Creating Three Constants
以下是三个打印语句的结果:
Tensor("Const:0", shape=(), dtype=float32)
Tensor("Const_1:0", shape=(2, 1), dtype=float32)
Tensor("Const_2:0", shape=(1, 2), dtype=float32)
第一个张量名是“Const:0
”。为了与其他张量相区别,字符串“Const
”被附加了下划线和数字。例如,第二个张量的名字是“Const_1:0
”。数字“1”是图中该常数的标识符。但是我们可以通过使用清单 6-3 中的 name 属性来改变张量的名称。
import tensorflow
tensor1 = tensorflow.constant(value=3.7, dtype=tensorflow.float32, name"firstConstant")
tensor2 = tensorflow.constant(value=[[0.5], [7]], dtype=tensorflow.float32, name"secondConstant")
tensor3 = tensorflow.constant(value=[[12, 9]], dtype=tensorflow.float32, name"thirdConstant")
print(tensor1)
print(tensor2)
print(tensor3)
Listing 6-3Setting Names of the Tensors Using the Name Attribute
三个打印报表的结果如下:
Tensor("firstConstant:0", shape=(), dtype=float32)
Tensor("secondConstant:0", shape=(2, 1), dtype=float32)
Tensor("thirdConstant:0", shape=(1, 2), dtype=float32)
因为每个张量都有一个唯一的名称,所以字符串没有附加数字。如果 name 属性的同一个值被用于多个张量,那么这个数字将如清单 6-4 所示使用。前两个张量被赋予值myConstant
,因此第二个张量被附加上数字“1”。
import tensorflow
tensor1 = tensorflow.constant(value=3.7, dtype=tensorflow.float32, name”myConstant”)
tensor2 = tensorflow.constant(value=[[0.5], [7]], dtype=tensorflow.float32, name”myConstant”)
tensor3 = tensorflow.constant(value=[[12, 9]], dtype=tensorflow.float32, name"thirdConstant")
print(tensor1)
print(tensor2)
print(tensor3)
Listing 6-4Two Tensors with the Same Value for the Name Attribute
列表 6-4 的结果如下:
Tensor("myConstant:0", shape=(), dtype=float32)
Tensor("myConstant_1:0", shape=(2, 1), dtype=float32)
Tensor("thirdConstant:0", shape=(1, 2), dtype=float32)
在清单 6-5 中,操作tensorflow.nn.top_k
用于返回向量的最大 K 值。换句话说,这个操作返回多个值作为输出。基于输出字符串,这两个输出被赋予字符串“TopKV2
”,但是在冒号后面有一个不同的数字。第一个输出的编号为“0”,第二个输出的编号为“1”。
import tensorflow
aa = tensorflow.nn.top_k([1, 2, 3, 4], 2)
print(aa)
Listing 6-5Operation Returning Multiple Outputs
打印输出是
TopKV2(values=<tf.Tensor 'TopKV2:0' shape=(2,) dtype=int32>, indices=<tf.Tensor 'TopKV2:1' shape=(2,) dtype=int32>)
到目前为止,我们已经能够打印出张量,但还不能评估它的结果。让我们创建一个 TF 会话来评估操作。
创建 TF 会话
TF 使用tensorflow.Session
类来表示客户端程序(通常是 Python 程序)和运行时环境之间的连接。一个tensorflow.Session
对象使用分布式 TF 运行时环境提供对本地机器中的设备和远程设备的访问。它还缓存了关于tensorflow.Graph
的信息,以便我们可以高效地重新运行同一个图。清单 6-6 创建了一个 TF 会话,用于评估单个常数张量的结果。待评估的张量被分配给fetches
属性。
会话被创建并返回到名为sess
的变量中。在使用tensorflow.Session.run()
方法运行会话以评估张量tensor1
之后,结果将是 3.7,这是常数值。这个方法运行tensorflow.Operation
并评估tensorflow.Tensor
。这种方法可以接受一个以上的张量进行评估,方法是将它们输入一个列表并将这个列表分配给fetches
属性。
import tensorflow
tensor1 = tensorflow.constant(value=3.7, dtype=tensorflow.float32)
sess = tensorflow.Session()
print(sess.run(fetches=tensor1))
sess.close()
Listing 6-6Evaluating a Single Constant Tensor
由于tensorflow.Session
拥有物理资源,如 CPU、GPU 和网络连接,它必须在完成执行后释放这些资源。根据清单 6-6 ,我们必须使用tensorflow.Session.close()
手动退出会话以释放资源。还有另一种创建会话的方法,它会自动关闭。这是通过使用清单 6-7 中的with
块来创建的。当会话在with
块内创建时,它将在到达块外后自动关闭。
import tensorflow
tensor1 = tensorflow.constant(value=3.7, dtype=tensorflow.float32)
with tensorflow.Session() as sess:
print(sess.run(fetches=tensor1))
Listing 6-7Creating a Session Using the With Block
我们还可以在tensorflow.Session.run()
方法中指定多个张量来获得它们的输出,如清单 6-8 所示。
import tensorflow
tensor1 = tensorflow.constant(value=3.7, dtype=tensorflow.float32)
tensor2 = tensorflow.constant(value=[[0.5], [7]], dtype=tensorflow.float32)
tensor3 = tensorflow.constant(value=[[12, 9]], dtype=tensorflow.float32)
with tensorflow.Session() as sess:
print(sess.run(fetches=[tensor1, tensor2, tensor3]))
Listing 6-8Evaluating More Than One Tensor
以下是三个评估张量的输出。
3.7
array([[ 0.5], [7.]], dtype=float32)
array([[ 12., 9.]], dtype=float32)
前面的例子只是打印张量的计算结果。可以存储这些值并在程序中重用它们。清单 6-9 在results
张量中返回评估结果。
import tensorflow
node1 = tensorflow.constant(value=3.7, dtype=tensorflow.float32)
node2 = tensorflow.constant(value=7.7, dtype=tensorflow.float32)
node3 = tensorflow.constant(value=9.1, dtype=tensorflow.float32)
with tensorflow.Session() as sess:
results = sess.run(fetches=[node1, node2, node3])
vIDX = 0
for value in results:
print("Value ", vIDX, " : ", value)
vIDX = vIDX + 1
Listing 6-9Evaluating More Than One Tensor
因为有三个张量要求值,所以三个输出都会存储到results
张量中,这是一个列表。使用for
循环,我们可以分别迭代和打印每个输出。输出如下:
Value 0 : 3.7
Value 1 : 7.7
Value 2 : 9.1
前面的例子只是计算了常数张量的值,没有应用任何运算。我们可以在这样的张量上应用一些运算。清单 6-10 创建两个张量,并使用tensorflow.add
操作将它们相加。这个操作接受两个张量,并将它们相加。两个张量必须具有相同的数据类型(即,dtype 属性)。它返回与输入张量相同类型的新张量。使用+运算符等同于使用tensorflow.add()
方法。
import tensorflow
tensor1 = tensorflow.constant(value=3.7, dtype=tensorflow.float32)
tensor2 = tensorflow.constant(value=7.7, dtype=tensorflow.float32)
add_op = tensorflow.add(tensor1, tensor2)
with tensorflow.Session() as sess:
add_result = sess.run(fetches=[add_op])
print("Result of addition : ", add_result)
Listing 6-10Adding Two Tensors Using the tensorflow.add Operation
print 语句的输出是
Result of addition : [11.4]
在图 6-2 中,清单 6-10 中的程序图是用 TB 可视化的。请注意,所有节点和边都有标签。这些标签是每个张量和操作的名字。使用默认值。在本章的后面,我们将学习如何在 TB 中可视化图形。
图 6-2
使用 TB 的图形可视化
操作的名字是描述性的,反映了它的工作,但张量的名字不是。我们可以将它们更改为num1
和num2
,并将图形可视化,如图 6-3 所示。
图 6-3
改变张量的名称
使用占位符的参数化图形
前面的图是静态的,因为它使用了常数张量。它总是接受相同的输入,并在每次求值时生成相同的输出。为了能够在程序每次运行时修改输入,我们可以使用tensorflow.placeholder
。换句话说,为了评估相同的操作但使用不同的输入,您应该使用tensorflow.placeholder
。注意placeholder
只能通过重新运行图形来改变它的值。
tensorflow.placeholder
接受如下三个参数:
-
dtype
:张量将接受的元素的数据类型。 -
shape
(可选–默认无):张量内数组的形状。如果没有指定,那么你可以给张量输入任何形状。 -
name
(可选–默认无):操作的名称。
它返回一个具有这些规格的张量。
我们可以修改清单 6-10 中的前一个例子,使用清单 6-11 中的tensorflow.placeholder
。之前运行会话时,tensorflow.Session.run()
只接受待评估的操作。当使用placeholders
时,该方法也将接受feed_dict
参数中占位符的初始值。feed_dict
参数接受值作为一个字典,将每个占位符的名称映射到它的值。
import tensorflow
tensor1 = tensorflow.placeholder(dtype=tensorflow.float32, shape=(), name="num1")
tensor2 = tensorflow.placeholder(dtype=tensorflow.float32, shape=(), name="num2")
add_op = tensorflow.add(tensor1, tensor2, name="Add_Op")
with tensorflow.Session() as sess:
add_result = sess.run(fetches=[add_op], feed_dict={tensor1: 3.7, tensor2: 7.7})
print("Result of addition : ", add_result)
Listing 6-11Parameterized Graph Using a Placeholder
为占位符分配与清单 6-10 中的常量相同的值,将返回相同的结果。使用占位符的好处是它们的值甚至可以在程序中更改,但是常量一旦创建就不能更改。
使用第三个占位符和乘法运算后,清单 6-12 使用不同的占位符值多次运行会话。它使用了一个for
循环,遍历由range()
本地 Python 函数返回的五个数字的列表。所有张量的值被设置为等于列表值,每次迭代一个值。使用tensorflow.add
运算将前两个张量的值相加。加法的结果被返回到add_op
张量中。然后使用tensorflow.multiply
运算将它的值乘以第三个张量。乘法结果在mul_op
张量中返回。使用*
操作符等同于使用tensorflow.add()
方法。与清单 6-12 中的mul_op
相比,清单 6-11 中的fetches
参数是一组add_op
。
import tensorflow
tensor1 = tensorflow.placeholder(dtype=tensorflow.float32, shape=(), name="num1")
tensor2 = tensorflow.placeholder(dtype=tensorflow.float32, shape=(), name="num2")
tensor3 = tensorflow.placeholder(dtype=tensorflow.float32, shape=(), name="num3")
add_op = tensorflow.add(tensor1, tensor2, name="Add_Op")
mul_op = tensorflow.multiply(add_op, tensor3, name="Add_Op")
with tensorflow.Session() as sess:
for num in range(5):
result = sess.run(fetches=[mul_op], feed_dict={tensor1: num, tensor2: num, tensor3: num})
print("Result at iteration ", num, " : ", result)
Listing 6-12Running the Session for Different Values for the Placeholders
print 语句的输出如下:
Result at iteration 0 : [0.0]
Result at iteration 1 : [2.0]
Result at iteration 2 : [8.0]
Result at iteration 3 : [18.0]
Result at iteration 4 : [32.0]
图 6-4 给出了之前图表的可视化。请注意,所有运算和张量都被重命名。前两个张量num1
和num2
与第一个操作Add_Op
相连。这个操作的结果被用作输入,第三个张量num3
作为第二个操作Mul_Op
的输入。
图 6-4
使用 TB 显示清单 6-12 中的图表
选择mul_op
张量作为清单 6-12 中fetches
列表的成员。为什么不直接选择add_op
?答案是选择图链中的最后一个张量进行评估。评估mul_op
将隐式评估图中的所有其他张量。如果选择“add_op”进行评估,那么mul_op
将不会被评估,因为add_op
不依赖于mul_op
,我们与评估它无关。但是mul_op
是依赖于add_mul
和其他所有张量的。因此,选择mul_op
进行评估。请记住,有可能使用一个以上的张量进行评估。
TF 变量
占位符用于分配内存以备将来使用。它们的主要用途是为模型提供输入数据以进行训练。如果要对不同的输入数据应用相同的操作,则将输入数据放入占位符中,然后通过为占位符指定不同的值来运行进程。
占位符未初始化,它们的值仅在运行时分配;换句话说,只有在调用 TensorFlow 之后。Session.run()是赋值的占位符。占位符允许创建不受约束的形状张量,这使它适合用于保存定型数据。
假设您想要将训练数据分配给占位符,并且您只知道每个样本由 35 个特征描述。我们还没有决定使用多少样本进行训练。我们可以创建一个占位符,该占位符接受具有未指定数量的样本但每个样本具有特定数量的特征(列)的张量,如下所示:
data_tensor = tensorflow.placeholder(dtype=tensorflow.float16, shape=[None, 35])
占位符只接受值,在赋值后不能更改。请记住,在清单 6-12 中,我们仅通过用新值重建图形来更改占位符的值。在同一图表中,不能更改占位符值。
ML 模型具有许多可训练的参数,这些参数被改变多次,直到达到它们的最佳值。我们如何允许一个张量多次改变它的值?这不是由常量和占位符提供的,而是由变量(tensorflow。变量())。
TF 变量与其他语言中使用的普通变量相同。它们被赋予初始值,并且这样的值可以在程序执行期间基于应用于它的操作被更新。占位符一旦在执行期间被赋值,就不允许修改数据。
一旦 tensorflow.constant()被调用,常量张量的值将被初始化,但变量在调用 tensorflow 后不会被初始化。变量()。通过在会话中运行 tensor flow . global _ variables _ initializer()操作,有一种简单的方法可以初始化程序中的所有全局变量。请注意,初始化变量并不意味着对它求值。变量需要在初始化后进行求值。清单 6-13 给出了一个创建名为“Var1”的单个变量的例子,它的值被初始化,然后变量被求值,最后,它的值被打印出来。
import tensorflow
var1 = tensorflow.Variable(initial_value=5.8, dtype=tensorflow.float32, name="Var1")
with tensorflow.Session() as sess:
init = tensorflow.global_variables_initializer()
sess.run(fetches=init)
var_value = sess.run(fetches=var1)
print("Variable value : ", var_value)
Listing 6-13Creating, Initializing, and Evaluating the Variable
打印语句将返回:
Variable value :5.8
请注意,会话有两次运行:第一次用于初始化所有变量,第二次用于评估变量。记住占位符是一个函数,而变量是一个类,因此它的名字以大写字母开头。
变量可以由任何类型和形状的张量初始化。这个张量的类型和形状将定义变量的类型和形状,这是不可改变的。变量值可以改变。在分布式环境中,变量可以存储一次,然后在所有设备上共享。它们有一个有助于调试的状态。此外,变量值可以在需要时保存和恢复。
变量初始化
初始化变量有不同的方法。所有变量初始化方法都可以设置变量的形状和数据类型。一种方法是使用先前初始化的变量的初始值。例如,清单 6-13 中名为“Var1”的变量由值为 5.8 的秩 0 张量初始化。这个初始化的变量可以用来初始化其他变量。注意,变量的初始值可以使用 tensorflow 的 initialized_value()方法返回。可变类。可以将初始值赋给另一个变量,如下所示。通过将“var1”的初始值乘以 5 来初始化变量“var3”。
var2 = tensorflow.Variable(initial_value=var1.initialized_value(), dtype=tensorflow.float32)
var3 = tensorflow.Variable(initial_value=var1.initialized_value()*5, dtype=tensorflow.float32)
变量可以基于 TF 中内置操作之一创建的另一个张量进行初始化。生成张量有不同的操作,包括:
-
tensorflow.lin_space(开始,停止,数量,名称=无)
-
tensorflow.range(start,limit=None,delta=1,dtype=None,name="range ")
-
tensorflow.zeros(shape,dtype=tf.float32,name=None)
-
tensorflow.ones(shape,dtype=tf.float32,name=None)
-
tensorflow.constant(value,dtype=None,shape=None,name="Const ",verify_shape=False)
它们与 NumPy 中对应的方法具有相同的含义。所有这些操作都返回指定数据类型和形状的张量。例如,我们可以创建一个 TensorFlow。变量(),其值使用 tensorflow.zeros()进行初始化,这将返回具有 12 个元素的 1D 行向量,如下所示:
var1 = tensorflow.Variable(tensorflow.zeros([12]))
使用 TB 的图形可视化
TF 旨在处理用大量数据训练的深度模型。TF 支持一套名为 TB 的可视化工具,有助于更容易地优化和调试 TF 程序。计算数据流图被可视化为表示操作的一组节点,这些节点通过表示输入和输出张量的边连接在一起。
下面是使用 TB 可视化一个简单图形的总结步骤:
-
建立数据流图。
-
使用 tensorflow.summary.FileWriter 将图形写入目录。
-
在保存的图形目录中启动 TB。
-
从网络浏览器访问 TB。
-
形象化图表。
让我们使用清单 6-14 中的代码进行可视化。这段代码创建了六个变量,分别输入到九个操作中。在编写了构建图形的说明之后,接下来是使用 FileWriter 保存它。tensorflow.summary.FileWriter()构造函数接受两个重要参数:“graph”和“logdir”。“graph”参数接受会话图,该图由“sess.graph”返回,假设会话变量名为“sess”。图形被导出到使用“logdir”参数指定的目录中。更改“logdir”以匹配您的系统。请注意,我们不必初始化变量,也不必运行会话,因为我们的目标不是执行图形,而只是可视化它。
import tensorflow
tensor1 = tensorflow.Variable(initial_value=4, dtype=tensorflow.float32, name="Var1")
tensor2 = tensorflow.Variable(initial_value=15, dtype=tensorflow.float32, name="Var2")
tensor3 = tensorflow.Variable(initial_value=-2, dtype=tensorflow.float32, name="Var3")
tensor4 = tensorflow.Variable(initial_value=1.8, dtype=tensorflow.float32, name="Var4")
tensor5 = tensorflow.Variable(initial_value=14, dtype=tensorflow.float32, name="Var5")
tensor6 = tensorflow.Variable(initial_value=8, dtype=tensorflow.float32, name="Var6")
op1 = tensorflow.add(x=tensor1, y=tensor2, name="Add_Op1")
op2 = tensorflow.subtract(x=op1, y=tensor1, name="Subt_Op1")
op3 = tensorflow.divide(x=op2, y=tensor3, name="Divide_Op1")
op4 = tensorflow.multiply(x=op3, y=tensor4, name="Mul_Op1")
op5 = tensorflow.multiply(x=op4, y=op1, name="Mul_Op2")
op6 = tensorflow.add(x=op5, y=2, name="Add_Op2")
op7 = tensorflow.subtract(x=op6, y=op2, name="Subt_Op2")
op8 = tensorflow.multiply(x=op7, y=tensor6, name="Mul_Op3")
op9 = tensorflow.multiply(x=op8, y=tensor5, name="Mul_Op4")
with tensorflow.Session() as sess:
writer = tensorflow.summary.FileWriter(logdir="\\AhmedGad\\TensorBoard\\", graph=sess.graph)
writer.close()
Listing 6-14Saving Dataflow Graph for Visualization Using TB
导出图表后,下一步是启动 TB 来访问图表。根据 TF 是安装在单独的虚拟环境(venv)中还是作为 site-packages 目录中的常规库,启动 TB 略有不同。
如果安装在 venv 中,那么必须使用位于 Python 安装的脚本目录下的 activate.bat 文件来激活 TF。假设脚本目录被添加到用户或系统路径变量环境中,并且 venv 文件夹被命名为“tensorflow ”,那么 TF 将根据以下命令被激活:
activate tensorflow
激活 TF 后,下一步是根据该命令将 TB 启动到保存图形的目录中:
tensorBoard --logdir=\\AhmedGad\\TensorBoard\\
如果 TF 安装在 site-packages 目录中,则可以通过发出以下命令来激活它:
python -m tensorboard.main --logdir="\\AhmedGad\\TensorBoard\\"
这将激活 TB,然后我们将准备好通过从 web 浏览器导航到“http://localhost:6006”来可视化图形。该图如图 6-5 所示。在这种情况下,更容易调试图形。例如,在图中比代码更容易检测到没有连接到图中任何其他节点的孤立节点。
图 6-5
使用 TB 实现数据流图的可视化
线性模型
线性模型具有方程 6-1 的一般形式。有 n 个输入变量xn,每个变量被赋予一个权重 w n 总共有 n 个权重。偏置 b 被添加到每个输入的 SOP 及其相应的偏置。
和=【w】**+【w】2
对于简单的线性模型,有输入数据、权重和偏差。在占位符和变量之间,哪一个选项最适合保存它们?通常,占位符用于在不同的输入上多次应用相同的操作。输入将被逐个分配给占位符,操作将应用于每个输入。变量用于存储可训练参数。因此,输入数据将被分配给一个占位符,但权重和偏差存储在变量中。记住使用 tensor flow . global _ variables _ initializer()来初始化变量。
清单 6-15 中给出了准备占位符和两个变量的代码。输入样本只有一个输入 x 1 和一个输出 y 。占位符“数据输入占位符”表示输入,占位符“数据输出占位符”表示输出。
因为每个样本只有一个输入变量,所以只有一个权重 w 1 。权重被表示为“weight_variable”变量,并被赋予初始值 0.2。偏差表示为变量“bias_variable ”,初始值为 0.1。请注意,占位符在 TensorFlow 中被赋值。使用“feed_dict”参数的 Session.run()方法。输入占位符被分配为 2.0,输出占位符被分配为 5.0。图形的可视化如图 6-6 所示。
请注意,run()方法的“fetches”参数被设置为包含三个元素的列表:“loss”、“error”和“output”。获取表示损失函数的“损失”张量,因为它是图中的目标张量。一旦它被评估,所有其他张量将被评估。提取“错误”和“输出”张量只是为了打印除预测输出之外的预测错误,就像代码末尾的打印语句一样。
注意张量“误差”和“损失”的区别。“误差”张量计算每个样本的预测输出和期望输出之间的平方误差。为了在单个值中总结所有误差,使用了张量“损失”。它计算所有平方误差的总和。
import tensorflow
data_input_placeholder = tensorflow.placeholder(dtype=tensorflow.float32, name="DataInput")
data_output_placeholder = tensorflow.placeholder(dtype=tensorflow.float32, name="DataOutput")
weight_variable = tensorflow.Variable(initial_value=0.1, dtype=tensorflow.float32, name="Weight")
bias_variable = tensorflow.Variable(initial_value=0.2, dtype=tensorflow.float32, name="Bias")
output = tensorflow.multiply(x=data_input_placeholder, y=weight_variable)
output = tensorflow.add(x=output, y=bias_variable)
diff = tensorflow.subtract(x=output, y=data_output_placeholder, name="Diff")
error = tensorflow.square(x=diff, name="PredictError")
loss = tensorflow.reduce_sum(input_tensor=error, name="Loss")
with tensorflow.Session() as sess:
writer = tensorflow.summary.FileWriter(logdir="\\AhmedGad\\TensorBoard\\", graph=sess.graph)
init = tensorflow.global_variables_initializer()
sess.run(fetches=init)
loss, predict_error, predicted_output = sess.run(fetches=[loss, error, output], feed_dict={data_input_placeholder: 2.0,data_output_placeholder: 5.0})
print("Loss : ", loss, "\nPredicted output : ", predicted_output,"\nPrediction error : ", predict_error)
writer.close()
Listing 6-15Preparing Inputs, Weight, and Bias for a Linear Model
根据分配给占位符和变量的值,打印消息的输出如下:
Loss : 21.16
Predicted output : 0.4
Prediction error : 21.16
预测输出为 0.4,期望输出为 5.0。存在等于 21.16 的误差。在提取的张量中只返回一个值,因为程序只处理一个样本。此外,损失值等于误差值,因为只有一个样本。我们可以对多个样本运行该程序。
图 6-6
具有一个输入的线性模型的数据流图的可视化
除了给占位符“data_input_placeholder”分配单个值之外,我们还可以在一个列表中分配多个值。这也适用于“数据输出占位符”占位符。请注意,它们必须具有相同的形状。使用两个样本后修改的程序如清单 6-16 所示。打印消息如下:
Loss : 51.41
Predicted output : [ 0.4 0.5]
Prediction error : [21.16 30.25]
这意味着第一个和第二个样本的预测误差分别为 21.16 和 30.25。所有平方误差之和为 51.41。因为损失函数有一个高值,所以我们必须更新参数(权重和偏差),以便最小化预测误差。
import tensorflow
data_input_placeholder = tensorflow.placeholder(dtype=tensorflow.float32, name="DataInput")
data_output_placeholder = tensorflow.placeholder(dtype=tensorflow.float32, name="DataOutput")
weight_variable = tensorflow.Variable(initial_value=0.1, dtype=tensorflow.float32, name="Weight")
bias_variable = tensorflow.Variable(initial_value=0.2, dtype=tensorflow.float32, name="Bias")
output = tensorflow.multiply(x=data_input_placeholder, y=weight_variable)
output = tensorflow.add(x=output, y=bias_variable)
diff = tensorflow.subtract(x=output, y=data_output_placeholder, name="Diff")
error = tensorflow.square(x=diff, name="PredictError")
loss = tensorflow.reduce_sum(input_tensor=error, name="Loss")
with tensorflow.Session() as sess:
init = tensorflow.global_variables_initializer()
sess.run(fetches=init)
loss, predict_error, predicted_output = sess.run(fetches=[loss, error, output], feed_dict={data_input_placeholder: [2.0, 3.0],data_output_placeholder: [5.0, 6.0]})
print("Loss : ", loss, "\nPredicted output : ", predicted_output,"\nPrediction error : ", predict_error)
Listing 6-16Running the TF Program for Multiple Samples
目前,没有办法更新参数。TF 中已经有许多优化器可以完成这项工作。
来自 TF Train API 的 GD 优化器
TF 提供了许多优化器来自动优化模型参数。GD 就是一个例子,慢慢改变每个参数的值,直到达到损耗最小的值。GD 根据损失对变量的导数的大小修改每个变量。这与“训练人工神经网络的反向传递”中第三章讨论的内容相同。“tensor flow . train”API 有一个名为“GradientDescentOptimizer”的类,它既可以计算导数,也可以优化参数。使用“GradientDescentOptimizer”后的程序如清单 6-17 所示。
import tensorflow
data_input_placeholder = tensorflow.placeholder(dtype=tensorflow.float32, name="DataInput")
data_output_placeholder = tensorflow.placeholder(dtype=tensorflow.float32, name="DataOutput")
weight_variable = tensorflow.Variable(initial_value=0.1, dtype=tensorflow.float32, name="Weight")
bias_variable = tensorflow.Variable(initial_value=0.2, dtype=tensorflow.float32, name="Bias")
output = tensorflow.multiply(x=data_input_placeholder, y=weight_variable, name="Multiply")
output = tensorflow.add(x=output, y=bias_variable, name="Add")
diff = tensorflow.subtract(x=output, y=data_output_placeholder, name="Diff")
error = tensorflow.square(x=diff, name="PredictError")
loss = tensorflow.reduce_sum(input_tensor=error, name="Loss")
train_optim = tensorflow.train.GradientDescentOptimizer(learning_rate=0.01, name="Optimizer")
minimizer = train_optim.minimize(loss=loss, name="Minimizer")
with tensorflow.Session() as sess:
writer = tensorflow.summary.FileWriter(graph=sess.graph, logdir="\\AhmedGad\\TensorBoard\\")
init = tensorflow.global_variables_initializer()
sess.run(fetches=init)
for k in range(1000):
_, data_loss, predict_error, predicted_output = sess.run(fetches=[minimizer,loss, error, output], feed_dict={data_input_placeholder: [1.0, 2.0],data_output_placeholder: [5.0, 6.0]})
print("Loss : ", data_loss,"\nPredicted output : ", predicted_output,"\nPrediction error : ", predict_error)
writer.close()
Listing 6-17Using GD for Optimizing the Model Parameters
该程序使用一个循环,该循环迭代 1000 次。对于每次迭代,当前参数用于预测输出,损失被计算,并且 GD 优化器更新参数以最小化损失。注意,“minimize()”操作返回一个最小化损失的操作。
迭代结束后,执行 print 语句。以下是它的输出:
Loss : 0.00323573
Predicted output : [ 4.951612 6.02990532]
Prediction error : [ 0.0023414 0.00089433]
由于 GD,损耗从 51.41 降低到仅为 0.0032。清单 6-17 中之前程序的图形如图 6-7 所示。
图 6-7
使用 GD 优化的线性模型的数据流图
定位要优化的参数
现在出现了一个重要的问题:优化器如何知道参数来改变它们的值?让我们看看它是怎么知道的。
运行会话后,将执行“最小化”操作。TF 将遵循图节点链来评估这样的操作。TF 发现“极小值”运算依赖于单个自变量,即“损失”张量。因此,我们的目标是最小化这样一个张量的值。我们怎样才能最小化这个张量?我们必须顺着图表往回走。
使用“tensorflow.reduce_sum()”运算来评估“损失”张量。因此,我们的目标是最小化“tensorflow.reduce_sum()”操作的结果。
退一步说,这个操作是用“误差”张量来评估的。因此,我们现在的目标是最小化“误差”张量。再退一步,我们发现“误差”张量依赖于“tensorflow.square()”运算。因此,我们必须最小化“tensorflow.square()”操作。这个操作的输入张量是“diff”张量。因此,我们的目标是最小化“差异”张量。因为“diff”张量是“tensorflow.subtract()”运算的结果,那么我们的目标就是最小化这个运算。
最小化“tensorflow.subtract()”要求我们最小化它的输入张量,即“output”和“data_output_placeholder”。看这两个张量,哪个可以修改?只有变量张量可以修改。因为“data_output_placeholder”不是变量而是占位符,所以我们不能修改。因此,为了最小化结果,我们只需要最小化“输出”张量。
“输出”张量是根据方程 6-1 计算的。它有三个输入:输入、权重和偏差,分别由张量“data_input_placeholder”、“weight_variable”和“bias_variable”表示。找这三个张量,只有“weight_variable”和“bias_variable”可以改变,因为它们是变量。因此,最终我们知道我们的目标是最小化“权重可变”和“偏差可变”张量。
为了最小化“tensor flow . train . gradientdescentoptimizer . minimize()”操作,我们必须更改“weight_variable”和“bias_variable”张量的值。这就是 TF 如何推导出,为了使损失最小化,应该使重量和偏置参数最小化。
建筑 FFNN
在本节中,将使用 TF Core API 为分类创建两个基本前馈神经网络(FFNNs)。我们将遵循之前使用 NumPy 构建 ANN 的相同步骤,但有所不同。
概括的步骤如下:
-
读取训练数据(输入和输出)。
-
构建神经网络层并准备其参数(权重、偏差和激活函数)。
-
建立损失函数以评估预测误差。
-
创建用于训练网络和更新其参数的训练循环。
-
使用新的看不见的测试数据评估训练的人工神经网络的准确性。
我们将从构建一个单层 FFANN 开始。
线性分类
表 6-2 给出了第一个分类问题的数据。基于颜色通道红色、绿色和蓝色将 RGB 颜色分类为红色或蓝色是一个二元分类问题。
表 6-2
RGB 颜色分类问题
|班级
|
红色
|
格林(姓氏);绿色的
|
蓝色
|
| --- | --- | --- | --- |
| 红色 | Two hundred and fifty-five | Zero | Zero |
| Two hundred and forty-eight | Eighty | sixty-eight |
| 蓝色 | Zero | nine | Two hundred and fifty-five |
| Sixty-seven | Fifteen | Two hundred and ten |
根据清单 6-18 ,创建了两个占位符(“training_inputs”和“training_outputs”)用于保存训练数据输入和输出。它们的数据类型设置为“float32 ”,但没有特定的形状。“training_inputs”占位符的形状是 N×3。那是什么意思?
通常,占位符用于保存模型的训练数据。训练数据的大小并不总是固定的。样本数量和/或特征数量可能会发生变化。例如,我们可以用 100 个样本训练一个模型,其中每个样本由 15 个特征表示。在本例中,占位符的形状为 100×15。假设我们后来决定将训练样本的数量更改为 50。占位符的形状必须改为 50×15。
import tensorflow
training_inputs = tensorflow.placeholder(shape=[None, 3], dtype=tensorflow.float32)
training_outputs = tensorflow.placeholder(shape=[None, 1], dtype=tensorflow.float32)
Listing 6-18Placeholders for the Training Data Inputs and Outputs
为了使生活更容易,TF 支持创建可变形状的占位符。占位符形状是根据分配给它的数据确定的。该形状可以在所有维度上变化,或者仅在某些维度上变化。如果我们决定使用 30 个特征,但是还没有决定训练样本的数量,那么形状是 N×15,其中 N 是样本的数量。向占位符输入 20 个样本,N 将被设置为 20。清单 6-18 中的两个占位符就是这种情况。若要让占位符泛型用于保存任意数量的训练样本,其形状将设置为(None,3)。None 表示该维度(代表样本数)没有静态大小。
准备好输入和输出后,下一步是决定网络架构,以准备它们的参数(权重和偏差)。因为数据很简单,我们可以画出来。清单 6-19 给出了用于绘制数据的代码。注意数据是三维的,因此图是三维的,如图 6-8 所示。
图 6-8
训练数据的 3D 散点图
import matplotlib.pyplot
import mpl_toolkits.mplot3d
figure3D = matplotlib.pyplot.figure()
axis3D = mpl_toolkits.mplot3d.Axes3D(figure3D)
red = [255, 248, 0, 67]
green = [0, 80, 9, 15]
blue = [0, 68, 255, 210]
axis3D.scatter(red, green, blue, color="black")
axis3D.set_xlabel(xlabel="Red")
axis3D.set_ylabel(ylabel="Green")
axis3D.set_zlabel(zlabel="Blue")
matplotlib.pyplot.show()
Listing 6-193D Scatter Plot of the Training Data
根据图 6-8 ,很明显这两类可以线性分离。红色类别的两个样本位于图的右侧,蓝色样本位于左侧。知道它是一个线性问题会引导我们不使用任何隐藏层。因此,网络架构只有输入层和输出层。因为每个样本使用三个要素表示,所以输入图层将只有三个输入,每个要素一个。网络架构如图 6-9 所示,其中X0= 1.0 为偏置输入, W 0 为偏置。W1、W2 和W3 是三个输入 R(红色)、G(绿色)和 B(蓝色)的权重。
图 6-9
用于 RGB 颜色线性分类的人工神经网络结构
清单 6-20 准备保存这些参数的变量。因为有三个输入,并且每个输入都有一个权重,所以根据“权重”变量,权重的形状是 3×1。形状为 3×1,以使输入和权重之间的矩阵乘法有效。形状 N×3 的输入数据可以乘以形状 3×1 的权重,结果将是 N×1。根据“偏差”变量,只有一个偏差。
import tensorflow
weights = tensorflow.Variable(initial_value=[[0.003], [0.001], [0.008]], dtype=tensorflow.float32)
bias = tensorflow.Variable(initial_value=[0.001], dtype=tensorflow.float32)
Listing 6-20Preparing ANN Parameter Variables
准备好数据、网络架构和参数后,接下来是将训练输入数据输入网络,预测其输出,并根据清单 6-21 计算损耗。使用“matmul()”运算将输入数据矩阵乘以权重向量,并将结果存储在“sop”张量中。根据等式 6-1,乘法的结果被加到偏差上。加法的结果存储在“sop_bias”张量中。然后将结果应用于由“tensorflow.nn.sigmoid()”操作定义的 sigmoid 函数,并返回到“预测”张量中。
import tensorflow
sop = tensorflow.matmul(a=training_inputs, b=weights, name="SOPs")
sop_bias = tensorflow.add(x=sop, y=bias)
predictions = tensorflow.nn.sigmoid(x=sop_bias, name="Sigmoid")
error = tensorflow.subtract(x=training_outputs, y=predictions, name="Error")
square_error = tensorflow.square(x=error, name="SquareError")
loss = tensorflow.reduce_sum(square_error, name="Loss")
train_optim = tensorflow.train.GradientDescentOptimizer(learning_rate=0.05, name="GradientDescent")
minimizer = train_op.minimize(loss, name="Minimizer")
Listing 6-21Using the Network Parameters to Predict the Outputs of the Training Data
预测输出后,接下来是测量损耗。首先,使用“subtract()”运算计算预测输出和正确输出之间的差异,并将结果存储在“误差”张量中。然后使用“平方”张量计算该误差的平方,并将结果存储到“平方误差”张量中。最后,通过将所有误差相加,平方误差减少为一个单一值。结果存储在“损失”张量中。
计算损失是为了了解我们当前距离损失为 0 的最佳结果有多远。基于损耗,在“train_optim”张量中初始化 GD 优化器,以更新网络参数,从而最小化损耗。更新操作被返回到“最小化”张量中。
至此,网络架构已经完成,可以使用输入和输出数据进行训练了。清单 6-22 中创建了两个 Python 列表来保存训练数据输入和输出。注意红色的类标签是“1.0”,蓝色的是“0.0”。使用“tensorflow”中的“feed_dict”参数将列表分配给占位符“training_inputs”和“training_outputs”。Session.run()"操作。注意,执行的目标是“最小化”操作。该会话经历多次迭代以更新 ANN 参数。
training_inputs_data = [[255, 0, 0],
[248, 80, 68],
[0, 0, 255],
[67, 15, 210]]
training_outputs_data = [[1.0],
[1.0],
[0.0],
[0.0]]
with tensorflow.Session() as sess:
init = tensorflow.global_variables_initializer()
sess.run(init)
for step in range(10):
sess.run(fetches=minimizer, feed_dict={training_inputs: training_inputs_data, training_outputs: training_outputs_data})
Listing 6-22
Training Data Inputs and Outputs
构建用于对表 6-2 中的两类问题进行分类的单层人工神经网络的完整代码在清单 6-23 中。
import tensorflow
# Preparing a placeholder for the training data inputs of shape (N, 3)
training_inputs = tensorflow.placeholder(shape=[None, 3], dtype=tensorflow.float32, name="Inputs")
# Preparing a placeholder for the training data outputs of shape (N, 1)
training_outputs = tensorflow.placeholder(shape=[None, 1], dtype=tensorflow.float32, name="Outputs")
# Initializing neural network weights of shape (3, 1)
weights = tensorflow.Variable(initial_value=[[0.003], [0.001], [0.008]], dtype=tensorflow.float32, name="Weights")
# Initializing the ANN bias
bias = tensorflow.Variable(initial_value=[0.001], dtype=tensorflow.float32, name="Bias")
# Calculating the SOPs by multiplying the weights matrix by the data inputs matrix
sop = tensorflow.matmul(a=training_inputs, b=weights, name="SOPs")
# Adding the bias to the SOPs
sop_bias = tensorflow.add(x=sop, y=bias, name="AddBias")
# Sigmoid activation function of the output layer neuron
predictions = tensorflow.nn.sigmoid(x=sop_bias, name="Sigmoid")
# Calculating the difference (error) between the ANN predictions and the correct outputs
error = tensorflow.subtract(x=training_outputs, y=predictions, name="Error")
# Square error.
square_error = tensorflow.square(x=error, name="SquareError")
# Measuring the prediction error of the network after being trained
loss = tensorflow.reduce_sum(square_error, name="Loss")
# Minimizing the prediction error using gradient descent optimizer
train_optim = tensorflow.train.GradientDescentOptimizer(learning_rate=0.05, name="GradientDescent")
minimizer = train_optim.minimize(loss, name="Minimizer")
# Training data inputs of shape (N, 3)
training_inputs_data = [[255, 0, 0],
[248, 80, 68],
[0, 0, 255],
[67, 15, 210]]
# Training data desired outputs
training_outputs_data = [[1.0],
[1.0],
[0.0],
[0.0]]
# Creating a TensorFlow Session
with tensorflow.Session() as sess:
writer = tensorflow.summary.FileWriter(logdir="\\AhmedGad\\TensorBoard\\", graph=sess.graph)
# Initializing the TensorFlow Variables (weights and bias)
init = tensorflow.global_variables_initializer()
sess.run(init)
# Training loop of the neural network
for step in range(10):
sess.run(fetches=minimizer, feed_dict={training_inputs: training_inputs_data, training_outputs: training_outputs_data})
# Class scores of training data
print("Expected Outputs for Train Data:\n", sess.run(fetches=[predictions, weights, bias], feed_dict={training_inputs: training_inputs_data}))
# Class scores of new test data
print("Expected Outputs for Test Data:\n", sess.run(fetches=predictions, feed_dict={training_inputs: [[230, 60, 76], [93, 52, 180]]}))
writer.close()
Listing 6-23The Complete Code for Classifying the Two-Class RGB Color Problem
在所有训练迭代之后,被训练的网络被用于预测训练样本和另外两个看不见的测试样本的输出。以下是清单 6-23 末尾打印语句的输出。网络能够正确预测所有训练和测试样本。
Expected Outputs for Train Data:
[[ 1.]
[ 1.]
[ 0.]
[ 0.]]
Expected Outputs for Test Data:
[[ 1.]
[ 0.]]
训练网络后的权重和偏差如下:
Weights:[[1.90823114], [0.11530305], [-4.13670015]],
Bias: [-0.00771546].
图 6-10 可视化了清单 6-23 中创建的图形。
图 6-10
单层人工神经网络图
非线性分类
现在,我们将构建一个人工神经网络来模拟具有两个输入的 XOR 门的操作。问题的真值表在表 6-3 中。因为问题很简单,我们可以把它画成图 6-11 来知道类是线性还是非线性可分的。
表 6-3
双输入异或门的真值表
|输出
|
A
|
B
|
| --- | --- | --- |
| 1 | one | Zero |
| Zero | one |
| 0 | Zero | Zero |
| one | one |
基于该图,很明显,这些类是非线性可分的。因此,我们必须使用隐藏层。根据第三章第的节设计 ANN 中的第一个例子,我们知道只有两个神经元的单个隐藏层就足够了。
图 6-11
双输入异或门图形
网络架构如图 6-12 所示。该隐藏层接受来自输入层的输入。基于它的权重和偏差,它的两个激活函数将产生两个输出。隐藏层的输出将被视为输出层的输入。使用它的激活函数,输出层产生输入样本的最终期望类。
图 6-12
双输入异或门的网络结构
完整的代码在清单 6-24 中。与上一个示例相比,有一些变化。使用“tensorflow.truncated_normal()”操作随机生成初始参数。隐藏层的输出张量“hidden_sigmoid”被用作输出层的输入。输出层的输出张量就是预测输出。剩下的代码类似于前面的例子。
import tensorflow
# Preparing a placeholder for the training data inputs of shape (N, 3)
training_inputs = tensorflow.placeholder(shape=[4, 2], dtype=tensorflow.float32, name="Inputs")
# Preparing a placeholder for the training data outputs of shape (N, 1)
training_outputs = tensorflow.placeholder(shape=[4, 1], dtype=tensorflow.float32, name="Outputs")
# Initializing the weights of the hidden layer of shape (2, 2)
hidden_weights = tensorflow.Variable(initial_value=tensorflow.truncated_normal(shape=(2,2), name="HiddenRandomWeights"), dtype=tensorflow.float32, name="HiddenWeights")
# Initializing the bias of the hidden layer of shape (1,2)
hidden_bias = tensorflow.Variable(initial_value=tensorflow.truncated_normal(shape=(1,2), name="HiddenRandomBias"), dtype=tensorflow.float32, name="HiddenBias")
# Calculating the SOPs by multiplying the weights matrix of the hidden layer by the data inputs matrix
hidden_sop = tensorflow.matmul(a=training_inputs, b=hidden_weights, name="HiddenSOPs")
# Adding the bias to the SOPs of the hidden layer
hidden_sop_bias = tensorflow.add(x=hidden_sop, y=hidden_bias, name="HiddenAddBias")
# Sigmoid activation function of the hidden layer outputs
hidden_sigmoid = tensorflow.nn.sigmoid(x=hidden_sop_bias, name="HiddenSigmoid")
# Initializing the weights of the output layer of shape (2, 1)
output_weights = tensorflow.Variable(initial_value=tensorflow.truncated_normal(shape=(2,1), name="OutputRandomWeights"), dtype=tensorflow.float32, name="OutputWeights")
# Initializing the bias of the output layer of shape (1,1)
output_bias = tensorflow.Variable(initial_value=tensorflow.truncated_normal(shape=(1,1), name="OutputRandomBias"), dtype=tensorflow.float32, name="OutputBias")
# Calculating the SOPs by multiplying the weights matrix of the hidden layer by the outputs of the hidden layer
output_sop = tensorflow.matmul(a=hidden_sigmoid, b=output_weights, name="Output_SOPs")
# Adding the bias to the SOPs of the hidden layer
output_sop_bias = tensorflow.add(x=output_sop, y=output_bias, name="OutputAddBias")
# Sigmoid activation function of the output layer outputs. These are the predictions.
predictions = tensorflow.nn.sigmoid(x=output_sop_bias, name="OutputSigmoid")
# Calculating the difference (error) between the ANN predictions and the correct outputs
error = tensorflow.subtract(x=training_outputs, y=predictions, name="Error")
# Square error.
square_error = tensorflow.square(x=error, name="SquareError")
# Measuring the prediction error of the network after being trained
loss = tensorflow.reduce_sum(square_error, name="Loss")
# Minimizing the prediction error using gradient descent optimizer
train_optim = tensorflow.train.GradientDescentOptimizer(learning_rate=0.01, name="GradientDescent")
minimizer = train_optim.minimize(loss, name="Minimizer")
# Training data inputs of shape (4, 2)
training_inputs_data = [[1, 0],
[0, 1],
[0, 0],
[1, 1]]
# Training data desired outputs
training_outputs_data = [[1.0],
[1.0],
[0.0],
[0.0]]
# Creating a TensorFlow Session
with tensorflow.Session() as sess:
writer = tensorflow.summary.FileWriter(logdir="\\AhmedGad\\TensorBoard\\", graph=sess.graph)
# Initializing the TensorFlow Variables (weights and bias)
init = tensorflow.global_variables_initializer()
sess.run(init)
# Training loop of the neural network
for step in range(100000):
print(sess.run(fetches=minimizer, feed_dict={training_inputs: training_inputs_data, training_outputs: training_outputs_data}))
# Class scores of training data
print("Expected Outputs for Train Data:\n", sess.run(fetches=[predictions, hidden_weights, output_weights, hidden_bias, output_bias], feed_dict={training_inputs: training_inputs_data}))
writer.close()
Listing 6-24The Complete Code for ANN Simulating XOR Gate with Two Inputs
完成训练过程后,样本被正确分类。以下是预测的输出:
[[0.96982265],
[0.96998841],
[0.0275135],
[0.0380362]]
训练后的网络参数如下:
-
隐藏层权重:[–6.27943468,–4.30125761],[–6.38489389,–4.31706429]]
-
隐藏层偏差:[[–8.8601017],[8.70441246]]
-
输出图层权重:[[2.49879336,6.37831974]]
-
输出层偏置:[[–4.06760359]]
图 6-13 可视化清单 6-24 的图形。
图 6-13
模拟双输入异或门的人工神经网络图
使用 CNN 的 CIFAR10 识别
我们之前讨论的例子帮助我们学习 TF 的基础知识并建立良好的知识。本节通过使用 TF 构建 CNN 来识别来自 CIFAR10 数据集的图像,扩展了这一知识。
准备培训数据
CIFAR10 数据集的二进制数据可从本页下载用于 Python:www.cs.toronto.edu/~kriz/cifar.html
。该数据集有 60,000 幅图像,分为训练集和测试集。有五个包含训练数据的二进制文件,其中每个文件有 10,000 个图像。图像是 32×32×3 大小的 RGB。训练文件被命名为“数据 _ 批处理 _1”、“数据 _ 批处理 _2”等等。有一个名为“test_batch”的测试数据文件,包含 10,000 个图像。有一个名为“batches.meta”的元数据文件,提供了数据集的详细信息,如类标签,即飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船和卡车。
因为数据集中的每个文件都是二进制的,所以我们必须对其进行解码,以便检索实际的图像数据。为了完成这项工作,创建了一个名为“unpickle_patch”的函数,如清单 6-25 中所定义。
def unpickle_patch(file):
patch_bin_file = open(file, 'rb')#Reading the binary file.
patch_dict = pickle.load(patch_bin_file, encoding="bytes")#Loading the details of the binary file into a dictionary.
return patch_dict#Returning the dictionary.
Listing 6-25Decoding the CIFAR10 Binary Data
该方法接受二进制文件路径,并将有关该文件的详细信息返回到“patch_dict”字典中。除了它们的类别标签之外,字典还具有文件中所有 10,000 个样本的图像数据。
有五个训练数据文件。为了解码整个训练数据,创建了一个名为“get_dataset_images”的新函数,如清单 6-26 所示。该函数接受数据集路径,只对五个训练文件的数据进行解码。首先,它使用“os.listdir()”函数列出数据集目录下的所有文件。所有文件名都返回到“文件名”列表中。
因为所有的训练和测试文件都位于同一个目录中,所以这个函数过滤这个路径下的文件,只返回训练文件。该函数使用“if”语句仅返回以“data_batch_”开头的文件,因为它区别于训练文件名。请注意,测试数据是在构建和训练 CNN 之后准备的。
def get_dataset_images(dataset_path, im_dim=32, num_channels=3):
num_files = 5#Number of training binary files in the CIFAR10 dataset.
images_per_file = 10000#Number of samples within each binary file.
files_names = os.listdir(patches_dir)#Listing the binary files in the dataset path.
dataset_array = numpy.zeros(shape=(num_files * images_per_file, im_dim, im_dim, num_channels))
dataset_labels = numpy.zeros(shape=(num_files * images_per_file), dtype=numpy.uint8)
index = 0#Index variable to count number of training binary files being processed.
for file_name in files_names:
if file_name[0:len(file_name) - 1] == "data_batch_":
print("Working on : ", file_name)
data_dict = unpickle_patch(dataset_path+file_name)
images_data = data_dict[b"data"]
#Reshaping all samples in the current binary file to be of 32x32x3 shape.
images_data_reshaped = numpy.reshape(images_data, newshape=(len(images_data), im_dim, im_dim, num_channels))
#Appending the data of the current file after being reshaped.
dataset_array[index * images_per_file:(index + 1) * images_per_file, :, :, :] = images_data_reshaped
#Appending the labels of the current file.
dataset_labels[index * images_per_file:(index + 1) * images_per_file] = data_dict[b"labels"]
index = index + 1#Incrementing the counter of the processed training files by 1 to accept new file.
return dataset_array, dataset_labels#Returning the training input data and output labels.
Listing 6-26Decoding All Training Files
每个训练文件通过调用“unpickle_patch”函数进行解码,其图像数据和它们的标签被返回到“data_dict”字典中。有五个训练文件,因此这样一个函数有五个类,其中每个调用返回一个字典。
基于该函数返回的字典,“get_dataset_images”函数将所有文件的细节(图像数据和类标签)连接成一个 NumPy 数组。可以使用“data”键从该字典中检索图像数据,并将其存储到“dataset _ array”NumPy 数组中,该数组存储所有训练文件中的所有解码图像。使用“labels”键检索类标签,并将其返回到“dataset _ labels”NumPy 数组中,该数组存储训练数据中所有图像的所有标签。函数返回“数据集数组”和“数据集标签”。
解码时,每个图像的数据返回为长度为 32×32×3=3,072 像素的 1D 向量。这个向量应该被重新塑造成三维的原始形状。这是因为在 TF 中创建的 CNN 层接受这个形状的图像。因此,“get_dataset_images”函数具有接受数据集图像的每个维度的大小的参数。第一个是“im_dim ”,表示行数/列数(它们相等),此外还有“num_channels ”,表示通道数。
在准备好训练数据之后,我们可以使用 TF 来建立和训练 CNN 模型。
构建 CNN
CNN 的数据流图是在名为“create_CNN”的函数中创建的,如清单 6-27 所示。它创建卷积(conv)、ReLU、最大池化、压差和 FC 层的堆栈。CNN 的架构如图 6-14 所示。它有三个 conv-relu-pool 组,后面是一个脱离层,最后是两个 FC 层。
图 6-14
CNN 架构
该函数返回最后 FC 层的结果。通常,每一层的输出都是下一层的输入。这要求相邻层的输出和输入的大小一致。请注意,对于每个 conv、ReLU 和 max 池层,需要指定一些参数,例如每个维度的跨度和填充。
def create_CNN(input_data, num_classes, keep_prop):
filters1, conv_layer1 = create_conv_layer(input_data=input_data, filter_size=7, num_filters=4)
relu_layer1 = tensorflow.nn.relu(conv_layer1)
max_pooling_layer1 = tensorflow.nn.max_pool(value=relu_layer1,
ksize=[1, 2, 2, 1],
strides=[1, 1, 1, 1],
padding="VALID")
filters2, conv_layer2 = create_conv_layer(input_data=max_pooling_layer1, filter_size=5, num_filters=3)
relu_layer2 = tensorflow.nn.relu(conv_layer2)
max_pooling_layer2 = tensorflow.nn.max_pool(value=relu_layer2,
ksize=[1, 2, 2, 1],
strides=[1, 1, 1, 1],
padding="VALID")
filters3, conv_layer3 = create_conv_layer(input_data=max_pooling_layer2, filter_size=3, num_filters=2)
relu_layer3 = tensorflow.nn.relu(conv_layer3)
max_pooling_layer3 = tensorflow.nn.max_pool(value=relu_layer3,
ksize=[1, 2, 2, 1],
strides=[1, 1, 1, 1],
padding="VALID")
flattened_layer = dropout_flatten_layer(previous_layer=max_pooling_layer3, keep_prop=keep_prop)
fc_result1 = fc_layer(flattened_layer=flattened_layer, num_inputs=flattened_layer.get_shape()[1:].num_elements(),
num_outputs=200)
fc_result2 = fc_layer(flattened_layer=fc_result1, num_inputs=fc_result1.get_shape()[1:].num_elements(),
num_outputs=num_classes)
print("Fully connected layer results : ", fc_result2)
return fc_result2#Returning the result of the last FC layer.
Listing 6-27Building the CNN Structure
CNN 的第一层直接处理输入数据。因此,“create_CNN”函数接受输入数据作为名为“input_data”的输入参数。这些数据是由“get_dataset_images”函数返回的。第一层是卷积层,根据清单 6-28 使用“创建 conv 层”功能创建。
“创建 conv 图层”函数接受输入数据、过滤器大小和过滤器数量。它返回输入数据与一组过滤器卷积的结果。该组中的滤波器根据输入数据的通道数量来设置其深度。因为通道数是 NumPy 数组中的最后一个元素,所以 index–1 用于返回通道数。这组过滤器被返回到“过滤器”变量中。
def create_conv_layer(input_data, filter_size, num_filters):
filters = tensorflow.Variable(tensorflow.truncated_normal(shape=(filter_size, filter_size, tensorflow.cast(input_data.shape[-1], dtype=tensorflow.int32), num_filters), stddev=0.05))
conv_layer = tensorflow.nn.conv2d(input=input_data,
filter=filters,
strides=[1, 1, 1, 1],
padding="VALID")
return filters, conv_layer#Returning the filters and the convolution layer result.
Listing 6-28Building Convolution Layer
卷积层是通过指定输入数据、过滤器和沿四个维度中的每一个维度的步长以及对“tensorflow.nn.conv2D”操作的填充来构建的。填充值“有效”意味着根据过滤器大小,输入图像的某些边界将在结果中丢失。
任何 conv 图层的结果都会被输入到使用“tensorflow.nn.relu”操作创建的 ReLU 图层中。它接受 conv 图层输出,并在应用 ReLU 激活函数后返回相同数量要素的张量。请记住,激活函数有助于创建输入和输出之间的非线性关系。ReLU 层的结果随后被提供给使用“tensorflow.nn.max_pool”操作创建的最大池层。请记住,共享层的目标是使识别转换不变。
“create_CNN”函数接受一个名为“keep_prop”的参数,该参数表示保留脱落层中神经元的概率,这有助于避免过拟合。dropout 层是使用“dropout_flatten_layer”函数实现的,如清单 6-29 所示。此函数返回用作 FC 层输入的展平数组。
def dropout_flatten_layer(previous_layer, keep_prop):
dropout = tensorflow.nn.dropout(x=previous_layer, keep_prob=keep_prop)
num_features = dropout.get_shape()[1:].num_elements()
layer = tensorflow.reshape(previous_layer, shape=(-1, num_features))#Flattening the results.
return layer
Listing 6-29Building Dropout Layer
因为最后一个 FC 层的输出神经元的数量应该等于数据集类的数量,所以数据集类的数量被用作“create_CNN”函数的另一个名为“num_classes”的输入参数。使用“fc_layer”函数创建 FC 层,根据清单 6-30 定义。此函数接受丢弃层的展平结果、展平结果中的要素数量以及 FC 层的输出神经元数量。基于输入和输出的数量,名为“fc_weights”的张量表示所创建的 fc 层的权重。它与展平层相乘,得到 FC 层的返回结果。
def fc_layer(flattened_layer, num_inputs, num_outputs):
fc_weights = tensorflow.Variable(tensorflow.truncated_normal(shape=(num_inputs, num_outputs), stddev=0.05))
fc_result1 = tensorflow.matmul(flattened_layer, fc_weights)
return fc_result1#Output of the FC layer (result of matrix multiplication).
Listing 6-30Building FC Layer
使用 TB 可视化后的计算图形如图 6-15 所示。a 部分给出了 CNN 的架构,直到最后的 max 池层,而 b 部分显示了剩余的步骤。
图 6-15
用于分类 CIFAR10 数据集的 CNN 图表
训练 CNN
在构建了 CNN 的计算图之后,接下来是针对先前准备的训练数据来训练它。根据清单 6-31 进行培训。代码从准备数据集和数据占位符的路径开始。请注意,路径应该更改为适合您的系统。然后它调用前面讨论过的函数。被训练的 CNN 的预测被用于测量网络的成本,该成本将使用 GD 优化器被最小化。一些张量有描述性的名称,以便以后测试 CNN 时更容易检索它们。
#Number of classes in the dataset. Used to specify the number of outputs in the last fully connected layer.
num_dataset_classes = 10
#Number of rows & columns in each input image. The image is expected to be rectangular Used to reshape the images and specify the input tensor shape.
im_dim = 32
#Number of channels in each input image. Used to reshape the images and specify the input tensor shape.
num_channels = 3
#Directory at which the training binary files of the CIFAR10 dataset are saved.
patches_dir = "\\AhmedGad\\cifar-10-python\\cifar-10-batches-py\\"
#Reading the CIFAR10 training binary files and returning the input data and output labels. Output labels are used to test the CNN prediction accuracy.
dataset_array, dataset_labels = get_dataset_images(dataset_path=patches_dir, im_dim=im_dim, num_channels=num_channels)
print("Size of data : ", dataset_array.shape)
# Input tensor to hold the data read in the preceding. It is the entry point of the computational graph.
# The given name of 'data_tensor' is useful for retrieving it when restoring the trained model graph for testing.
data_tensor = tensorflow.placeholder(tensorflow.float32, shape=[None, im_dim, im_dim, num_channels], name="data_tensor")
# Tensor to hold the outputs label.
# The name "label_tensor" is used for accessing the tensor when testing the saved trained model after being restored.
label_tensor = tensorflow.placeholder(tensorflow.float32, shape=[None], name="label_tensor")
#The probability of dropping neurons in the dropout layer. It is given a name for accessing it later.
keep_prop = tensorflow.Variable(initial_value=0.5, name="keep_prop")
#Building the CNN architecture and returning the last layer which is the fully connected layer.
fc_result2 = create_CNN(input_data=data_tensor, num_classes=num_dataset_classes, keep_prop=keep_prop)
# Predictions propabilities of the CNN for each training sample.
# Each sample has a probability for each of the 10 classes in the dataset.
# Such a tensor is given a name for accessing it later.
softmax_propabilities = tensorflow.nn.softmax(fc_result2, name="softmax_probs")
# Predictions labels of the CNN for each training sample.
# The input sample is classified as the class of the highest probability.
# axis=1 indicates that maximum of values in the second axis is to be returned. This returns that maximum class probability of each sample.
softmax_predictions = tensorflow.argmax(softmax_propabilities, axis=1)
#Cross entropy of the CNN based on its calculated propabilities.
cross_entropy = tensorflow.nn.softmax_cross_entropy_with_logits(logits=tensorflow.reduce_max(input_tensor=softmax_propabilities, reduction_indices=[1]), labels=label_tensor)
#Summarizing the cross entropy into a single value (cost) to be minimized by the learning algorithm.
cost = tensorflow.reduce_mean(cross_entropy)
#Minimizing the network cost using the Gradient Descent optimizer with a learning rate is 0.01.
error = tensorflow.train.GradientDescentOptimizer(learning_rate=.01).minimize(cost)
#Creating a new TensorFlow Session to process the computational graph.
sess = tensorflow.Session()
#Writing summary of the graph to visualize it using TensorBoard.
tensorflow.summary.FileWriter(logdir="\\AhmedGad\\TensorBoard\\", graph=sess.graph)
#Initializing the variables of the graph.
sess.run(tensorflow.global_variables_initializer())
# Because it may be impossible to feed the complete data to the CNN on normal machines, it is recommended to split the data into a number of patches.
# A subset of the training samples is used to create each path. Samples for each path can be randomly selected.
num_patches = 5#Number of patches
for patch_num in numpy.arange(num_patches):
print("Patch : ", str(patch_num))
percent = 80 #percent of samples to be included in each path.
#Getting the input-output data of the current path.
shuffled_data, shuffled_labels = get_patch(data=dataset_array, labels=dataset_labels, percent=percent)
#Data required for cnn operation. 1)Input Images, 2)Output Labels, and 3)Dropout probability
cnn_feed_dict = {data_tensor: shuffled_data,
label_tensor: shuffled_labels,
keep_prop: 0.5}
# Training the CNN based on the current patch.
# CNN error is used as input in the run to minimize it.
# SoftMax predictions are returned to compute the classification accuracy.
softmax_predictions_, _ = sess.run([softmax_predictions, error], feed_dict=cnn_feed_dict)
#Calculating number of correctly classified samples.
correct = numpy.array(numpy.where(softmax_predictions_ == shuffled_labels))
correct = correct.size
print("Correct predictions/", str(percent * 50000/100), ' : ', correct)
#Closing the session
sess.close()
Listing 6-31Training
CNN
不是将整个训练数据提供给 CNN,而是返回数据的一个子集。这有助于将数据调整到可用的内存量。根据清单 6-32 ,使用“get_patch”函数返回子集。该函数接受输入数据、标签和从数据中返回的样本百分比。然后,它根据指定的百分比返回数据的子集。
def get_patch(data, labels, percent=70):
num_elements = numpy.uint32(percent*data.shape[0]/100)
shuffled_labels = labels#Temporary variable to hold the data after being shuffled.
numpy.random.shuffle(shuffled_labels)#Randomly reordering the labels.
return data[shuffled_labels[:num_elements], :, :, :], shuffled_labels[:num_elements]
Listing 6-32Splitting Dataset into Patches
保存已训练的模型
根据清单 6-33 ,在训练 CNN 后,保存该模型以备以后测试使用。您还应该更改保存模型的路径,以适合您的系统。
#Saving the model after being trained.
saver = tensorflow.train.Saver()
save_model_path = "\\AhmedGad\\model\\"
save_path = saver.save(sess=sess, save_path=save_model_path+"model.ckpt")
print("Model saved in : ", save_path)
Listing 6-33Saving the Trained CNN Model
构建和训练 CNN 的完整代码
在完成了从读取数据到保存训练模型的项目的所有部分后,图 6-16 给出了步骤的总结。清单 6-34 给出了训练 CNN 的完整代码。保存训练好的模型后,它将用于预测测试数据的类别标签。
图 6-16
构建使用 CIFAR10 数据集训练的 CNN 的步骤总结
import pickle
import tensorflow
import numpy
import matplotlib.pyplot
import scipy.misc
import os
def get_dataset_images(dataset_path, im_dim=32, num_channels=3):
"""
This function accepts the dataset path, reads the data, and returns it after being reshaped to match the requirements of the CNN.
:param dataset_path:Path of the CIFAR10 dataset binary files.
:param im_dim:Number of rows and columns in each image. The image is expected to be rectangular.
:param num_channels:Number of color channels in the image.
:return:Returns the input data after being reshaped and output labels.
"""
num_files = 5#Number of training binary files in the CIFAR10 dataset.
images_per_file = 10000#Number of samples within each binary file.
files_names = os.listdir(patches_dir)#Listing the binary files in the dataset path.
# Creating an empty array to hold the entire training data after being reshaped. The dataset has 5 binary files holding the data. Each binary file has 10,000 samples. Total number of samples in the dataset is 5*10,000=50,000.
# Each sample has a total of 3,072 pixels. These pixels are reshaped to form a RGB image of shape 32x32x3.
# Finally, the entire dataset has 50,000 samples and each sample of shape 32x32x3 (50,000x32x32x3).
dataset_array = numpy.zeros(shape=(num_files * images_per_file, im_dim, im_dim, num_channels))
#Creating an empty array to hold the labels of each input sample. Its size is 50,000 to hold the label of each sample in the dataset.
dataset_labels = numpy.zeros(shape=(num_files * images_per_file), dtype=numpy.uint8)
index = 0#Index variable to count number of training binary files being processed.
for file_name in files_names:
# Because the CIFAR10 directory does not only contain the desired training files and has some other files, it is required to filter the required files. Training files start by 'data_batch_' which is used to test whether the file is for training or not.
if file_name[0:len(file_name) - 1] == "data_batch_":
print("Working on : ", file_name)
# Appending the path of the binary files to the name of the current file.
# Then the complete path of the binary file is used to decoded the file and return the actual pixels values.
data_dict = unpickle_patch(dataset_path+file_name)
# Returning the data using its key 'data' in the dictionary.
# Character b is used before the key to tell it is binary string.
images_data = data_dict[b"data"]
#Reshaping all samples in the current binary file to be of 32x32x3 shape.
images_data_reshaped = numpy.reshape(images_data, newshape=(len(images_data), im_dim, im_dim, num_channels))
#Appending the data of the current file after being reshaped.
dataset_array[index * images_per_file:(index + 1) * images_per_file, :, :, :] = images_data_reshaped
#Appending the labels of the current file.
dataset_labels[index * images_per_file:(index + 1) * images_per_file] = data_dict[b"labels"]
index = index + 1#Incrementing the counter of the processed training files by 1 to accept new file.
return dataset_array, dataset_labels#Returning the training input data and output labels.
def unpickle_patch(file):
"""
Decoding the binary file.
:param file:File path to decode its data.
:return: Dictionary of the file holding details including input data and output labels.
"""
patch_bin_file = open(file, 'rb')#Reading the binary file.
patch_dict = pickle.load(patch_bin_file, encoding="bytes")#Loading the details of the binary file into a dictionary.
return patch_dict#Returning the dictionary.
def get_patch(data, labels, percent=70):
"""
Returning patch to train the CNN.
:param data: Complete input data after being encoded and reshaped.
:param labels: Labels of the entire dataset.
:param percent: Percent of samples to get returned in each patch.
:return: Subset of the data (patch) to train the CNN model.
"""
#Using the percent of samples per patch to return the actual number of samples to get returned.
num_elements = numpy.uint32(percent*data.shape[0]/100)
shuffled_labels = labels#Temporary variable to hold the data after being shuffled.
numpy.random.shuffle(shuffled_labels)#Randomly reordering the labels.
# The previously specified percent of the data is returned starting from the beginning until meeting the required number of samples.
# The labels indices are also used to return their corresponding input images samples.
return data[shuffled_labels[:num_elements], :, :, :], shuffled_labels[:num_elements]
def create_conv_layer(input_data, filter_size, num_filters):
"""
Builds the CNN convolution (conv) layer.
:param input_data:patch data to be processed.
:param filter_size:#Number of rows and columns of each filter. It is expected to have a rectangular filter.
:param num_filters:Number of filters.
:return:The last fully connected layer of the network.
"""
# Preparing the filters of the conv layer by specifying its shape.
# Number of channels in both input image and each filter must match.
# Because number of channels is specified in the shape of the input image as the last value, index of -1 works fine.
filters = tensorflow.Variable(tensorflow.truncated_normal(shape=(filter_size, filter_size, tensorflow.cast(input_data.shape[-1], dtype=tensorflow.int32), num_filters), stddev=0.05))
print("Size of conv filters bank : ", filters.shape)
# Building the convolution layer by specifying the input data, filters, strides along each of the 4 dimensions, and the padding.
# Padding value of 'VALID' means the some borders of the input image will be lost in the result based on the filter size.
conv_layer = tensorflow.nn.conv2d(input=input_data,
filter=filters,
strides=[1, 1, 1, 1],
padding="VALID")
print("Size of conv result : ", conv_layer.shape)
return filters, conv_layer#Returning the filters and the convolution layer result.
def create_CNN(input_data, num_classes, keep_prop):
"""
Builds the CNN architecture by stacking conv, relu, pool, dropout, and fully connected layers.
:param input_data:patch data to be processed.
:param num_classes:Number of classes in the dataset. It helps to determine the number of outputs in the last fully connected layer.
:param keep_prop:probability of keeping neurons in the dropout layer.
:return: last fully connected layer.
"""
#Preparing the first convolution layer.
filters1, conv_layer1 = create_conv_layer(input_data=input_data, filter_size=7, num_filters=4)
# Applying ReLU activation function over the conv layer output.
# It returns a new array of the same shape as the input array.
relu_layer1 = tensorflow.nn.relu(conv_layer1)
print("Size of relu1 result : ", relu_layer1.shape)
# Max-pooling is applied to the ReLU layer result to achieve translation invariance. It returns a new array of a different shape from the input array relative to the strides and kernel size used.
max_pooling_layer1 = tensorflow.nn.max_pool(value=relu_layer1,
ksize=[1, 2, 2, 1],
strides=[1, 1, 1, 1],
padding="VALID")
print("Size of maxpool1 result : ", max_pooling_layer1.shape)
#Similar to the previous conv-relu-pool layers, new layers are just stacked to complete the CNN architecture.
#Conv layer with 3 filters and each filter is of size 5x5.
filters2, conv_layer2 = create_conv_layer(input_data=max_pooling_layer1, filter_size=5, num_filters=3)
relu_layer2 = tensorflow.nn.relu(conv_layer2)
print("Size of relu2 result : ", relu_layer2.shape)
max_pooling_layer2 = tensorflow.nn.max_pool(value=relu_layer2,
ksize=[1, 2, 2, 1],
strides=[1, 1, 1, 1],
padding="VALID")
print("Size of maxpool2 result : ", max_pooling_layer2.shape)
#Conv layer with 2 filters and a filter size of 5x5.
filters3, conv_layer3 = create_conv_layer(input_data=max_pooling_layer2, filter_size=3, num_filters=2)
relu_layer3 = tensorflow.nn.relu(conv_layer3)
print("Size of relu3 result : ", relu_layer3.shape)
max_pooling_layer3 = tensorflow.nn.max_pool(value=relu_layer3,
ksize=[1, 2, 2, 1],
strides=[1, 1, 1, 1],
padding="VALID")
print("Size of maxpool3 result : ", max_pooling_layer3.shape)
#Adding dropout layer before the fully connected layers to avoid overfitting.
flattened_layer = dropout_flatten_layer(previous_layer=max_pooling_layer3, keep_prop=keep_prop)
#First fully connected (FC) layer. It accepts the result of the dropout layer after being flattened (1D).
fc_result1 = fc_layer(flattened_layer=flattened_layer, num_inputs=flattened_layer.get_shape()[1:].num_elements(),
num_outputs=200)
#Second fully connected layer accepting the output of the previous fully connected layer. Number of outputs is equal to the number of dataset classes.
fc_result2 = fc_layer(flattened_layer=fc_result1, num_inputs=fc_result1.get_shape()[1:].num_elements(),
num_outputs=num_classes)
print("Fully connected layer results : ", fc_result2)
return fc_result2#Returning the result of the last FC layer.
def dropout_flatten_layer(previous_layer, keep_prop):
"""
Applying the dropout layer.
:param previous_layer: Result of the previous layer to the dropout layer.
:param keep_prop: Probability of keeping neurons.
:return: flattened array.
"""
dropout = tensorflow.nn.dropout(x=previous_layer, keep_prob=keep_prop)
num_features = dropout.get_shape()[1:].num_elements()
layer = tensorflow.reshape(previous_layer, shape=(-1, num_features))#Flattening the results.
return layer
def fc_layer(flattened_layer, num_inputs, num_outputs):
"""
building a fully connected (FC) layer.
:param flattened_layer: Previous layer after being flattened.
:param num_inputs: Number of inputs in the previous layer.
:param num_outputs: Number of outputs to be returned in such FC layer.
:return:
"""
#Preparing the set of weights for the FC layer. It depends on the number of inputs and number of outputs.
fc_weights = tensorflow.Variable(tensorflow.truncated_normal(shape=(num_inputs, num_outputs), stddev=0.05))
#Matrix multiplication between the flattened array and the set of weights.
fc_result1 = tensorflow.matmul(flattened_layer, fc_weights)
return fc_result1#Output of the FC layer (result of matrix multiplication).
#***********************************************************
#Number of classes in the dataset. Used to specify number of outputs in the last FC layer.
num_dataset_classes = 10
#Number of rows & columns in each input image. The image is expected to be rectangular Used to reshape the images and specify the input tensor shape.
im_dim = 32
# Number of channels in each input image. Used to reshape the images and specify the input tensor shape.
num_channels = 3
#Directory at which the training binary files of the CIFAR10 dataset are saved.
patches_dir = "\\AhmedGad\\cifar-10-python\\cifar-10-batches-py\\"
#Reading the CIFAR10 training binary files and returning the input data and output labels. Output labels are used to test the CNN prediction accuracy.
dataset_array, dataset_labels = get_dataset_images(dataset_path=patches_dir, im_dim=im_dim, num_channels=num_channels)
print("Size of data : ", dataset_array.shape)
# Input tensor to hold the data read in the preceding. It is the entry point of the computational graph.
# The given name of 'data_tensor' is useful for retrieving it when restoring the trained model graph for testing.
data_tensor = tensorflow.placeholder(tensorflow.float32, shape=[None, im_dim, im_dim, num_channels], name="data_tensor")
# Tensor to hold the outputs label.
# The name "label_tensor" is used for accessing the tensor when testing the saved trained model after being restored.
label_tensor = tensorflow.placeholder(tensorflow.float32, shape=[None], name="label_tensor")
#The probability of dropping neurons in the dropout layer. It is given a name for accessing it later.
keep_prop = tensorflow.Variable(initial_value=0.5, name="keep_prop")
#Building the CNN architecture and returning the last layer which is the FC layer.
fc_result2 = create_CNN(input_data=data_tensor, num_classes=num_dataset_classes, keep_prop=keep_prop)
# Predictions propabilities of the CNN for each training sample.
# Each sample has a probability for each of the 10 classes in the dataset.
# Such a tensor is given a name for accessing it later.
softmax_propabilities = tensorflow.nn.softmax(fc_result2, name="softmax_probs")
# Predictions labels of the CNN for each training sample.
# The input sample is classified as the class of the highest probability.
# axis=1 indicates that maximum of values in the second axis is to be returned. This returns that maximum class probability of each sample.
softmax_predictions = tensorflow.argmax(softmax_propabilities, axis=1)
#Cross entropy of the CNN based on its calculated propabilities.
cross_entropy = tensorflow.nn.softmax_cross_entropy_with_logits(logits=tensorflow.reduce_max(input_tensor=softmax_propabilities, reduction_indices=[1]),labels=label_tensor)
#Summarizing the cross entropy into a single value (cost) to be minimized by the learning algorithm.
cost = tensorflow.reduce_mean(cross_entropy)
#Minimizing the network cost using the Gradient Descent optimizer with a learning rate is 0.01.
ops = tensorflow.train.GradientDescentOptimizer(learning_rate=.01).minimize(cost)
#Creating a new TensorFlow Session to process the computational graph.
sess = tensorflow.Session()
#Writing summary of the graph to visualize it using TensorBoard.
tensorflow.summary.FileWriter(logdir="\\AhmedGad\\TensorBoard\\", graph=sess.graph)
#Initializing the variables of the graph.
sess.run(tensorflow.global_variables_initializer())
# Because it may be impossible to feed the complete data to the CNN on normal machines, it is recommended to split the data into a number of patches. A subset of the training samples is used to create each path. Samples for each path can be randomly selected.
num_patches = 5#Number of patches
for patch_num in numpy.arange(num_patches):
print("Patch : ", str(patch_num))
percent = 80 #percent of samples to be included in each path.
#Getting the input-output data of the current path.
shuffled_data, shuffled_labels = get_patch(data=dataset_array, labels=dataset_labels, percent=percent)
#Data required for cnn operation. 1)Input Images, 2)Output Labels, and 3)Dropout probability
cnn_feed_dict = {data_tensor: shuffled_data,
label_tensor: shuffled_labels,
keep_prop: 0.5}
# Training the CNN based on the current patch.
# CNN error is used as input in the run to minimize it.
# SoftMax predictions are returned to compute the classification accuracy.
softmax_predictions_, _ = sess.run([softmax_predictions, ops], feed_dict=cnn_feed_dict)
#Calculating number of correctly classified samples.
correct = numpy.array(numpy.where(softmax_predictions_ == shuffled_labels))
correct = correct.size
print("Correct predictions/", str(percent * 50000/100), ' : ', correct)
#Closing the session
sess.close()
#Saving the model after being trained.
saver = tensorflow.train.Saver()
save_model_path = " \\AhmedGad\\model\\"
save_path = saver.save(sess=sess, save_path=save_model_path+"model.ckpt")
print("Model saved in : ", save_path)
Listing 6-34Complete Code to Train CNN for CIFAR10 Dataset
准备测试数据
在测试训练好的模型之前,需要准备测试数据并恢复之前训练好的模型。测试数据准备与训练数据类似,只是只有一个二进制文件需要解码。根据清单 6-35 ,根据修改后的“get_dataset_images”函数对测试文件进行解码。请注意,它与用于解码训练数据的函数同名,因为它假设有两个单独的脚本,一个用于训练,另一个用于测试。该函数调用“unpickle_patch”函数,就像之前对训练数据所做的一样。
def get_dataset_images(test_path_path, im_dim=32, num_channels=3):
data_dict = unpickle_patch(test_path_path)
images_data = data_dict[b"data"]
dataset_array = numpy.reshape(images_data, newshape=(len(images_data), im_dim, im_dim, num_channels))
return dataset_array, data_dict[b"labels"]
Listing 6-35Saving the Trained CNN Model
测试训练好的 CNN 模型
根据图 6-16 ,保存的模型将用于预测测试数据的标签。在准备好测试数据并恢复训练好的模型后,我们可以根据清单 6-36 开始测试模型。值得一提的是,在训练 CNN 时,会话的运行是为了最小化成本。在测试中,我们对最小化成本不再感兴趣,我们只想返回数据样本的预测。这就是为什么 TF 会话通过获取“softmax_propabilities”和“softmax_predictions”张量来仅返回预测。
当图形被恢复时,在训练阶段名为“data_tensor”的张量将被分配测试数据,而名为“label_tensor”的张量将被分配样本标签。
另一个有趣的点是,dropout 层的保持概率“keep_prop”现在设置为 1.0。这意味着不丢弃任何神经元(即使用所有神经元)。这是因为我们只是在决定放弃哪些神经元后才使用预训练模型。现在我们只是使用之前的模型,对任何修改都不感兴趣。
#Dataset path containing the testing binary file to be decoded.
patches_dir = "\\AhmedGad\\cifar-10-python\\cifar-10-batches-py\\"
dataset_array, dataset_labels = get_dataset_images(test_path_path=patches_dir + "test_batch", im_dim=32, num_channels=3)
print("Size of data : ", dataset_array.shape)
sess = tensorflow.Session()
#Restoring the previously saved trained model.
saved_model_path = '\\AhmedGad\\model\\'
saver = tensorflow.train.import_meta_graph(saved_model_path+'model.ckpt.meta')
saver.restore(sess=sess, save_path=saved_model_path+'model.ckpt')
#Initializing the variables.
sess.run(tensorflow.global_variables_initializer())
graph = tensorflow.get_default_graph()
softmax_propabilities = graph.get_tensor_by_name(name="softmax_probs:0")
softmax_predictions = tensorflow.argmax(softmax_propabilities, axis=1)
data_tensor = graph.get_tensor_by_name(name="data_tensor:0")
label_tensor = graph.get_tensor_by_name(name="label_tensor:0")
keep_prop = graph.get_tensor_by_name(name="keep_prop:0")
#keep_prop is equal to 1 because there is no more interest to remove neurons in the testing phase.
feed_dict_testing = {data_tensor: dataset_array,
label_tensor: dataset_labels,
keep_prop: 1.0}
#Running the session to predict the outcomes of the testing samples.
softmax_propabilities_, softmax_predictions_ = sess.run([softmax_propabilities, softmax_predictions], feed_dict=feed_dict_testing)
#Assessing the model accuracy by counting number of correctly classified samples.
correct = numpy.array(numpy.where(softmax_predictions_ == dataset_labels))
correct = correct.size
print("Correct predictions/10,000 : ", correct)
#Closing the session
sess.close()
Listing 6-36Testing the Trained CNN
至此,我们已经成功构建了用于分类 CIFAR10 数据集图像的 CNN 模型。在下一章中,保存的经过训练的 CNN 模型被部署到使用 Flask 创建的 web 服务器上,以供互联网用户访问。
七、部署预训练模型
在构建 DL 模型的过程中,创建模型是最难的一步,但它不是终点。为了从创建的模型中获益,用户应该远程访问它们。用户的反馈将有助于改进模型性能。
本章讨论如何在线部署预训练模型以供互联网用户访问。使用 Flask micro web framework,使用 Python 创建 web 应用。使用 HTML(超文本标记语言)、CSS(级联样式表)和 JavaScript,构建简单的网页,以允许用户向服务器发送和接收 HTTP(超文本传输协议)请求。用户使用 web 浏览器访问应用,并能够将图像上传到服务器。基于部署的模型,图像被分类,并且其类别标签被返回给用户。此外,还创建了一个 Android 应用来访问 web 服务器。本章假设读者对 HTML、CSS、JavaScript 和 Android 有基本的了解。读者可以按照此链接中的说明安装烧瓶( http://flask.pocoo.org/docs/1.0/installation/
)。
应用概述
图 7-1 总结了本章的目标应用,它扩展了第六章中的步骤:使用 TF 构建 CNN 的数据流图,然后使用 CIFAR10 数据集对其进行训练;最后,保存训练好的模型,为部署做好准备。使用 Flask,可以创建一个监听来自客户端的 HTTP 请求的 web 应用。客户端从使用 HTML、CSS 和 JavaScript 创建的网页访问 web 应用。
图 7-1
应用概述
服务器加载保存的模型,打开一个会话,并等待来自客户端的请求。客户端使用 web 浏览器打开网页,该网页允许将图像上传到服务器进行分类。服务器根据大小确保映像属于 CIFAR10 数据集。之后,将图像输入模型进行分类。由模型预测的标签在对客户端的响应中被返回。最后,客户端在网页上显示标签。为了针对 Android 设备进行定制,创建了向服务器发送 HTTP 请求并接收分类标签的 Android 应用。
在这一章中,我们将介绍应用中涉及的每个步骤,直到成功完成为止。
烧瓶简介
Flask 是一个用于构建 web 应用的微框架。尽管是微型的,但它不支持其他框架所支持的一些功能。它被称为“微”,因为它具有构建应用所需的核心需求。稍后使用扩展,您可以添加所需的功能。Flask 让用户决定使用什么。例如,它不附带特定的数据库,而是让用户自由选择使用哪个数据库。
Flask 使用 WSGI (Web 服务器网关接口)。WSGI 是服务器处理来自 Python web 应用的请求的方式。它被视为服务器和应用之间的通信通道。服务器收到请求后,WSGI 处理请求,并将其发送给用 Python 编写的应用。WSGI 接收应用的响应,并将其返回给服务器。然后,服务器响应客户端。Flask 使用 Werkzeug,这是一个用于实现请求和响应的 SWGI 实用程序。Flask 还使用 jinja2,这是用于构建模板网页的模板引擎,这些网页随后会动态填充数据。
为了开始使用 Flask,让我们根据清单 7-1 讨论最小 Flask 应用。构建 Flask 应用要做的第一件事是从 Flask 类创建一个实例。使用类构造函数创建了app
实例。构造函数的强制import_name
参数非常重要。它用于定位应用资源。如果在FlaskApp\firstApp.py
中找到该应用,则将该参数设置为FlaskApp
。例如,如果在应用目录下有一个 CSS 文件,则此参数用于定位该文件。
import flask
app = flask.Flask(import_name="FlaskApp")
@app.route(rule="/")
def testFunc():
return "Hello"
app.run()
Listing 7-1Minimal Flask Application
Flask 应用由一组函数组成,每个函数都与一个 URL(统一资源定位器)相关联。当客户端导航到一个 URL 时,服务器向应用发送请求以响应客户端。应用使用与该 URL 相关联的查看功能进行响应。视图函数的返回是呈现在网页上的响应。这就留下了一个问题:我们如何将一个函数与一个 URL 关联起来?幸运的是,答案很简单。
route()装饰器
首先,该函数是一个可以接受参数的常规 Python 函数。在清单 7-1 中,函数被调用testFunc()
,但是还不接受任何参数。它返回字符串Hello
。这意味着当客户端访问与该函数相关联的 URL 时,字符串Hello
将呈现在屏幕上。使用route()
装饰器将 URL 与函数相关联。它被称为“路由”,因为它的工作方式类似于路由器。路由器接收输入消息并决定遵循哪个输出接口。此外,装饰器接收一个输入 URL 并决定调用哪个函数。
route()
装饰器接受一个名为rule
的参数,该参数表示与装饰器下面的视图函数相关联的 URL。根据清单 7-1,route()
装饰器将代表主页的 URL /
关联到名为testFunc()
的视图函数。
完成这个简单的应用后,下一步是通过使用 Flask 类的run()
方法运行脚本来激活它。运行应用的结果如图 7-2 所示。根据输出,服务器默认监听 IP(互联网协议)地址127.0.0.1
,这是一个环回地址。这意味着服务器只是在端口 5000 上侦听来自本地主机的请求。
图 7-2
运行第一个 Flask 应用后的控制台输出
当使用网络浏览器访问位于127.0.0.1:5000/
地址的服务器时,将调用testFunc()
函数。其输出呈现在网络浏览器上,如图 7-3 所示。
图 7-3
访问与testFunc()
功能相关的 URL
我们可以使用run()
方法的host
和port
参数覆盖 IP 和端口的默认值。覆盖这些参数的默认值后,run()
方法如下:
app.run(host="127.0.0.5", port=6500)
图 7-4 显示了将主机设置为127.0.0.5
并将端口号设置为 6500 后的结果。只要确保没有应用正在使用选定的端口。
图 7-4
通过覆盖run()
方法的默认值来监听不同的主机和端口
对于服务器收到的每个请求,HTTP 请求的方法、URL 和响应代码都打印在控制台上。例如,当访问主页时,请求返回 200,这意味着页面被成功定位。访问一个不存在的页面比如127.0.0.5:6500/abc
会返回 404 作为响应代码,意味着没有找到这个页面。这有助于调试应用。
图 7-5
服务器收到的请求
run()
方法的另一个有用的参数名为debug
。它是一个布尔参数,用于决定是否打印调试信息。它默认为 False。当这样一个参数被设置为 True 时,我们就不必为代码中的每一个变化重新启动服务器。这在应用的开发中非常有用。每次更改后只需保存应用的 Python 文件,服务器就会自动重新加载。如图 7-6 ,服务器开始使用端口号 6500。更改为 6300 后,服务器会自动重新加载以监听新端口。
图 7-6
当调试处于活动状态时,每次更改后自动重新加载服务器
添加规则 url 方法
之前,URL 已经使用route()
装饰器绑定到函数。装饰器在 Flask 类内部调用add_url_rule()
方法。这个方法和其他的装饰器做同样的工作。我们可以根据清单 7-2 直接使用这个方法。它像以前一样接受了rule
参数,但是增加了view_func
参数。它指定哪个视图功能与该规则相关联。它被设置为函数名,即testFunc
。当我们使用route()
装饰器时,函数是隐式已知的。函数正好在装饰器的下面。请注意,对该方法的调用不必正好在函数下面。运行这段代码会返回与之前相同的结果。
import flask
app = flask.Flask(import_name="FlaskApp")
def testFunc():
return "Hello"
app.add_url_rule(rule="/", view_func=testFunc)
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-2Using the add_url_rule() Method
可变规则
以前的规则是静态的。可以向规则中添加可变部分。它被视为一个参数。可变部分添加在两个尖括号<>
之间规则的静态部分之后。我们可以修改前面的代码,根据清单 7-3 接受一个表示名称的变量参数。主页的规则现在是/<name>
,而不仅仅是/
。如果客户端导航到 URL 127.0.0.5:6300/Gad
,那么name
被设置为Gad
。
import flask
app = flask.Flask(import_name="FlaskApp")
def testFunc(name):
return "Hello : " + name
app.add_url_rule(rule="/<name>", view_func=testFunc, endpoint="home")
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-3Adding Variable Part to the Rule
请注意,view 函数中必须有一个参数来接受 URL 的可变部分。因此,testFunc()
被修改为接受一个与规则中定义的名称相同的参数。函数的返回被修改为也返回参数name
的值。图 7-7 显示了使用变量法则后的结果。改变变量部分,访问主页,就会改变输出。
图 7-7
在规则中使用可变部分
可以在规则中使用多个可变部分。根据清单 7-4 ,该规则接受两个参数,代表由-
分隔的名和姓。
import flask
app = flask.Flask(import_name="FlaskApp")
def testFunc(fname, lname):
return "Hello : " + fname + " " + lname
app.add_url_rule(rule="/<fname>-<lname>", view_func=testFunc, endpoint="home")
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-4Using More Than One Variable Part
访问 URL 127.0.0.5:6300/Ahmed-Gad
会将fname
设置为Ahmed
,将lname
设置为Gad
。结果如图 7-8 所示。
图 7-8
规则中有多个可变部分
端点
add_url_rule()
方法接受名为endpoint
的第三个参数。它是规则的标识符,有助于多次重用同一规则。注意,这个论点也存在于route()
装饰器中。默认情况下,端点的值被设置为 view 函数。这里有一个场景,其中endpoint
很重要。
假设网站有两个页面,每个页面分配一个规则。第一条规则是/
,第二条规则是/addNums/<num1>-<num2>
。第二页有两个代表两个数字的参数。这些数字加在一起,结果返回到主页进行渲染。清单 7-5 给出了创建这些规则及其视图功能的代码。给testFunc()
视图函数一个等于home
的endpoint
值。
add_func()
视图函数接受两个参数,它们是与之相关的规则的可变部分。因为这些参数的值是字符串,所以使用int()
函数将它们的值转换成整数。然后它们被一起加入到num3
变量中。这个函数的返回不是数字,而是使用redirect()
方法重定向到另一个页面。这种方法接受重定向位置。
import flask
app = flask.Flask(import_name="FlaskApp")
def testFunc(result):
return "Result is : " + result
app.add_url_rule(rule="/<result>", view_func=testFunc, endpoint="home")
def add_func(num1, num2):
num3 = int(num1) + int(num2)
return flask.redirect(location=flask.url_for("home", result=num3))
app.add_url_rule(rule="/addNums/<num1>-<num2>", view_func=add_func)
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-5Using Endpoint to Redirect Between Pages
我们可以简单地使用端点来返回 URL,而不是对 URL 进行硬编码。使用from_url()
方法从端点返回 URL。除了规则接受的任何变量之外,它还接受规则的端点。因为主页规则接受一个名为result
的变量,所以我们必须在from_url()
方法中添加一个名为result
的参数,并为其赋值。分配给这种变量的值是num3
。通过导航到 URL 127.0.0.5:6300/addNums/1-2
,数字 1 和 2 相加,结果是 3。该函数然后重定向到主页,在这里规则的result
变量被设置为等于 3。
使用端点比硬编码 URL 更容易。我们可以简单地将redirect()
方法的location
参数赋给规则/
,但是不推荐这样做。假设主页的 URL 从/
更改为/home
,那么我们必须在对主页的每个引用中应用这一更改。而且,假设 URL 很长,比如127.0.0.5:6300/home/page1
。每次我们需要引用这个 URL 时,都要键入它,这很烦人。端点被视为 URL 的抽象。
证明使用端点重要性的另一个例子是,站点管理员可能决定更改页面的地址。如果页面通过复制和粘贴它的 URL 被多次引用,那么我们必须到处改变 URL。使用端点可以避免这个问题。端点不像 URL 那样频繁更改,因此即使页面的 URL 发生变化,站点也将保持活动状态。请注意,不使用端点进行重定向会使向规则传递可变部分变得困难。
清单 7-5 中的代码接受从 URL 添加的输入数字。我们可以创建一个简单的 HTML 表单,允许用户输入这些数字。
HTML 表单
add_url_rule()
方法(当然还有route()
装饰器)接受另一个名为methods
的参数。它接受指定规则响应的 HTTP 方法的列表。该规则可以响应多种类型的方法。
有两种常见的 HTTP 方法:GET 和 POST。GET 方法是默认方法,它发送未加密的数据。POST 方法用于将 HTML 表单数据发送到服务器。让我们创建一个简单的表单,接受两个数字,并将它们发送到 Flask 应用进行添加和呈现。
清单 7-6 给出了创建一个表单的 HTML 代码,该表单除了一个提交类型的输入外,还有两个数字类型的输入。表单方法设置为post
。海东的网址是 http://127.0.0.5:6300/form
。该操作表示表单数据将被发送到的页面。有一个规则将该 URL 与一个视图函数相关联,该函数从表单中获取数字,将它们相加,并呈现结果。表单元素的名称非常重要,因为在提交表单后,只有带有 name 属性的元素才会被发送到服务器。元素名称被用作标识符来检索 Flask 应用中的元素数据。
<html>
<header>
<title>HTML Form</title>
</header>
<body>
<form method="post" action="http://127.0.0.5:6300/form">
<span>Num1 </span>
<input type="number" name="num1"><br>
<span>Num2 </span>
<input type="number" name="num2"><br>
<input type="submit" name="Add">
</form>
</body>
</html>
Listing 7-6
HTML Form
HTML 表单如图 7-9 所示。
图 7-9
带有两个数字输入的 HTML 表单
提交表单后,清单 7-7 中的 Flask 应用检索表单数据。规则/form
与handle_form()
函数相关联。该规则只响应 POST 类型的 HTTP 消息。在函数内部,使用flask.request.form
字典返回表单元素。每个 HTML 表单元素的名称被用作该对象的索引,以便返回它们的值。例如,名称为num1
的第一个表单元素的值通过使用flask.request.form["num1"]
返回。
import flask
app = flask.Flask(import_name="FlaskApp")
def handle_form():
num1 = flask.request.form["num1"]
num1 = int(num1)
num2 = flask.request.form["num2"]
num2 = int(num2)
result = num1 + num2
result = str(result)
return "Result is : " + result
app.add_url_rule(rule="/form", view_func=handle_form, methods=["POST"])
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-7Flask Application to Retrieve the HTML Form Data
因为索引flask.request.form
对象返回的值是一个字符串,所以必须使用int()
函数将它转换成一个整数。将两个数相加后,它们的结果存储在result
变量中。这个变量被转换成一个字符串,以便将其值与一个字符串连接起来。连接的字符串由handle_form
视图函数返回。渲染结果如图 7-10 所示。
图 7-10
两个数字 HTML 表单元素相加的结果
文件上传
在 Flask 中上传文件非常简单,除了一些变化之外,与前面的例子相似。在 HTML 表单中创建一个类型为file
的输入。此外,表单加密类型属性enctype
被设置为multipart/form-data
。用于上传文件的 HTML 表单的代码如清单 7-8 所示。表格的截图如图 7-11 所示。
图 7-11
用于上传文件的 HTML 表单
<html>
<header>
<title>HTML Form</title>
</header>
<body>
<form method="post" enctype="multipart/form-data" action="http://127.0.0.5:6300/form">
<span>Select File to Upload</span><br>
<input type="file" name="fileUpload"><br>
<input type="submit" name="Add">
</form>
</body>
</html>
Listing 7-8HTML Form for Uploading a File
选择要上传的图像后,它将被发送到根据清单 7-9 创建的 Flask 应用。该规则再次设置为仅响应 POST 类型的 HTTP 消息。之前,我们使用了flask.request.form
对象来检索数据字段。现在,我们使用flask.request.files
返回要上传的文件的详细信息。表单输入的名称fileUpload
被用作该对象的索引,以返回要上传的文件。注意,flask.request
是一个全局对象,它从客户机 web 页面接收数据。
为了保存文件,使用filename
属性检索其名称。不建议根据用户提交的文件名保存文件。一些文件名被设置成伤害服务器。为了安全保存文件,使用了werkzeug.secure_filename()
功能。记得导入werkzeug
模块。
import flask, werkzeug
app = flask.Flask(import_name="FlaskApp")
def handle_form():
file = flask.request.files["fileUpload"]
file_name = file.filename
secure_file_name = werkzeug.secure_filename(file_name)
file.save(dst=secure_file_name)
return "File uploaded successfully."
app.add_url_rule(rule="/form", view_func=handle_form, methods=["POST"])
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-9Flask Application to Upload Files to the Server
安全文件名返回到secure_file_name
变量。最后,通过调用save()
方法永久保存文件。这种方法接受保存文件的目标位置。因为只使用了文件名,所以它将保存在 Flask 应用 Python 文件的当前目录中。
HTML 内部烧瓶应用
前一个视图函数的返回输出只是一个显示在 web 页面上的文本,没有任何格式。Flask 支持在 Python 代码中生成 HTML 内容,这有助于更好地呈现结果。清单 7-10 给出了一个例子,其中tesFunc()
视图函数的返回结果是 HTML 代码,其中
元素呈现结果。数字
7-12 shows the result.
图 7-12
使用 HTML 代码格式化视图函数的输出
import flask, werkzeug
app = flask.Flask(import_name="FlaskApp")
def testFunc():
return "<html><body><h1>Hello</h1></body></html>"
app.add_url_rule(rule="/", view_func=testFunc)
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-10Generating HTML Inside Python
在 Python 代码中生成 HTML 使得调试代码变得困难。最好把 Python 和 HTML 分开。这就是为什么 Flask 支持使用 Jinja2 模板引擎的模板。
烧瓶模板
不是在 Python 文件中键入 HTML 代码,而是创建一个单独的 HTML 文件(即模板)。使用render_template()
方法在 Python 中呈现这样的模板。HTML 文件被称为模板,因为它不是静态文件。该模板可多次用于不同的数据输入。
为了在 Python 代码中定位 Flask 模板,创建了一个名为templates
的文件夹来保存所有的 HTML 文件。假设 Flask Python 文件命名为firstApp.py
,HTML 文件命名为hello.html
,项目结构如图 7-13 所示。在清单 7-11 中,创建了hello.html
文件来打印与清单 7-10 中完全相同的 Hello 消息。
图 7-13
使用模板后的项目结构
<html>
<header>
<title>HTML Template</title>
</header>
<body>
<h1>Hello</h1>
</body>
</html>
Listing 7-11Template to Print Hello Message
清单 7-12 中给出了呈现该模板的 Python 代码。与主页关联的视图函数的返回结果是render_template()
方法的输出。这个方法接受一个名为template_name_or_list
的参数来指定模板文件名。请注意,该参数可以接受单个名称或一系列名称。当用多个名称指定一个列表时,将呈现现有的第一个模板。该示例的渲染结果与图 7-12 相同。
import flask, werkzeug
app = flask.Flask(import_name="FlaskApp")
def testFunc():
return flask.render_template(template_name_or_list="hello.html")
app.add_url_rule(rule="/", view_func=testFunc)
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-12Python Code to Render an HTML Template
动态模板
模板目前是静态的,因为它们每次都以相同的方式呈现。我们可以通过使用可变数据使它们动态化。Jinja2 支持在模板内部添加占位符。在渲染模板时,这些占位符会被评估 Python 表达式的输出所替换。在要打印表达式输出的地方,用{{...}}
将表达式括起来。清单 7-13 给出了使用变量name
的 HTML 代码。
<html>
<header>
<title>HTML Template with an Expression</title>
</header>
<body>
<h1>Hello {{name}}</h1>
</body>
</html>
Listing 7-13HTML Code with an Expression
下一步是在根据清单 7-14 为变量name
传递值之后呈现模板。要呈现的模板中的变量作为参数与它们的值一起被传递到render_template
中。访问主页的结果如图 7-14 。
图 7-14
用表达式呈现模板的结果
import flask, werkzeug
app = flask.Flask(import_name="FlaskApp")
def testFunc():
return flask.render_template(template_name_or_list="hello.html", name="Ahmed")
app.add_url_rule(rule="/", view_func=testFunc)
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-14Rendering Flask Template with an Expression
变量name
的值是静态类型的,但它可以使用变量规则或 HTML 形式动态生成。清单 7-15 给出了用于创建接受名称的变量规则的代码。根据规则的变量部分,视图函数必须有一个名为的参数。然后这个参数的值被分配给render_template()
方法的 name 参数。然后根据图 7-15 将该值传递给要渲染的模板。
图 7-15
将从变量规则接收的值传递给 Flask 模板
import flask, werkzeug
app = flask.Flask(import_name="FlaskApp")
def testFunc(name):
return flask.render_template(template_name_or_list="hello.html", name=name)
app.add_url_rule(rule="/<name>", view_func=testFunc)
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-15Variable Rule to Pass Value to Flask Template
我们还可以在 HTML 代码中插入 Python 语句、注释和行语句,每个语句都有不同的占位符。语句用{% ... %}
括起来,注释用{# ... #}
括起来,行语句用# ... ##
括起来。清单 7-16 给出了一个例子,其中插入了一个 Python for 循环来打印从 0 到 4 的五个数字,每个数字都在< h1 > HTML 元素中。循环中的每条语句都用{%...%}
括起来。
Python 使用缩进来定义块。因为 HTML 内部没有缩进,for
循环的结尾用endfor
标记。该文件的渲染结果如图 7-16 所示。
图 7-16
使用 Python 循环呈现模板
<html>
<header>
<title>HTML Template with Expression</title>
</header>
<body>
{%for k in range(5):%}
<h1>{%print(k)%}</h1>
{%endfor%}
</body>
</html>
Listing 7-16Embedding a Python Loop Inside Flask Template
静态文件
CSS 和 JavaScript 文件等静态文件用于样式化网页并使其动态化。与模板类似,创建了一个文件夹来存储静态文件。文件夹名为static
。如果我们要创建一个名为style.css
的 CSS 文件和一个名为simpeJS.js
的 JavaScript 文件,项目结构将如图 7-17 所示。
图 7-17
包含模板和静态文件的项目结构
Python 代码与清单 7-15 中的代码相同,只是没有使用规则的变量部分。清单 7-17 显示了hello.html
文件的内容。值得一提的是 HTML 文件是如何链接到 JavaScript 和 CSS 文件的。通常,使用的属性是text/javascript
。此外,使用标签添加 CSS 文件,其中rel
属性被设置为stylesheet
。新的是如何定位这些文件。
<html>
<header>
<title>HTML Template with Expression</title>
<script type="text/javascript" src="{{url_for(endpoint='static', filename='simpleJS.js')}}"></script>
<link rel="stylesheet" href="{{url_for(endpoint='static', filename='style.css')}}">
</header>
<body>
{%for k in range(5):%}
<h1 onclick="showAlert({{k}})">{%print(k)%}</h1>
{%endfor%}
</body>
</html>
Listing 7-17HTML File Linked with CSS and JavaScript Files
在<script>
和<link>
标签中,url_for()
方法被用在一个表达式中来定位文件。该方法的endpoint
属性被设置为 static,这意味着您应该查看项目结构下名为static
的文件夹。该方法接受另一个名为filename
的参数,它引用静态文件的文件名。
CSS 文件的内容在清单 7-18 中给出。它只针对任何<h1>
元素,并通过在其上下添加虚线来修饰它们的文本。
h1 {
text-decoration: underline overline;
}
Listing 7-18Content of the CSS File
清单 7-19 给出了 JavaScript 文件的内容。它有一个名为showAlert
的函数,该函数接受一个连接到字符串并打印在警报中的参数。当 HTML 模板中代表五个数字的任何一个<h1>
元素被点击时,这个函数被调用。与元素相关的数字作为参数传递给函数,以便打印出来。
function showAlert(num){
alert("Number is " + num)
}
Listing 7-19Content of the JavaScript File
当点击带有文本1
的数字<h1>
元素时,输出如图 7-18 所示。
图 7-18
点击带有文本1
的第二个
元素的结果
至此,我们已经有了对 Flask 的介绍,这足以让我们开始部署预训练模型。在接下来的部分中,针对 Fruits 360 和 CIFAR10 数据集的预训练模型将被部署到 web 服务器,以便 Flask 应用能够访问它们,从而对客户端上传的图像进行分类。
使用 Fruits 360 数据集部署训练模型
我们要部署的第一个模型是第五章中使用 Fruits 360 数据集训练的模型,并使用 GA 优化。Flask 应用由两个主要页面组成。
第一页是主页。它有一个 HTML 表单,允许用户选择图像文件。该文件被上传到服务器。第二页完成了大部分工作。它遵循第章和第章中的相同步骤。它在上传到服务器后读取图像,提取其特征,使用 STD 过滤特征,使用预训练的 ANN 预测图像类别标签,最后允许用户返回主页选择另一个图像进行分类。该应用具有图 7-19 中定义的结构。下面详细讨论一下应用。
图 7-19
水果 360 识别应用结构
清单 7-20 开始了构建应用的第一步。导入整个应用所需的所有模块。创建 Flask 类的实例时,将构造函数的import_name
参数设置为父目录的名称,即FruitsApp
。到目前为止,只创建了一个规则。该规则将主页/
的 URL 绑定到查看功能homepage
。应用使用主机127.0.0.5
、端口号6302
和主动调试模式运行。
import flask, werkzeug, skimage.io, skimage.color, numpy, pickle
app = flask.Flask(import_name="FruitsApp")
def homepage():
return flask.render_template(template_name_or_list="home.html")
app.add_url_rule(rule="/", view_func=homepage, endpoint="homepage")
app.run(host="127.0.0.5", port=6300, debug=True)
Listing 7-20Basic Structure of the Fruits 360 Recognition Application
当用户访问主页http://127.0.0.5:6302
时,视图函数homepage()
使用render_template()
方法呈现home.html
模板。使用的关联端点是homepage
,它与视图函数的名称相同。注意,省略这个端点不会改变任何东西,因为默认端点实际上等于视图函数名。清单 7-21 中给出了home.html
页面的内容。
<html>
<header>
<title>Select Image</title>
<link rel="stylesheet" href="{{url_for(endpoint='static', filename='style.css')}}">
</header>
<body>
<h1>Select an Image from the Fruits 360 Dataset</h1>
<form enctype="multipart/form-data" action="{{url_for(endpoint='extract')}}" method="post">
<input type="file" name="img"><br>
<input type="submit">
</form>
</body>
</html>
Listing 7-21Implementation of the home.html Page
该页面创建一个 HTML 表单,表单中有一个名为img
的输入,表示要上传的文件。记住表单的加密类型属性enctype
被设置为multipart/form-data
,方法为post
。该操作表示表单数据将提交到的页面。提交表单后,其数据被发送到另一个页面,以对上传的图像文件进行分类。为了避免对 URL 进行硬编码,目标规则的端点被设置为extract
,用于通过url_for()
方法获取其 URL。为了能够在 HTML 页面中运行这个表达式,它被包含在{{...}}
之间。
在页面头中,样式表静态文件style.css
通过使用接受endpoint
和url_for()
方法的filename
参数的表达式链接到页面。记住,静态文件的endpoint
被设置为static
。filename
参数被设置为目标静态文件名。CSS 文件的内容将在后面讨论。图 7-20 显示选择图像文件后的主页屏幕。在提交表单之后,所选择的文件细节被发送到视图函数extractFeatures
,该函数与端点extract
相关联,用于进一步的处理。
图 7-20
上传水果 360 数据集图片的主页截图
清单 7-22 给出了与/extract
规则相关联的extractFeatures
视图函数的代码。请注意,该规则只监听 POST HTTP 方法。extractFeatures
视图函数响应之前提交的表单。它使用字典flask.request.files
返回上传的图像文件。使用图像文件的filename
属性返回文件名。为了使保存文件更加安全,使用secure_filename()
函数返回安全文件名,该函数接受原始文件名并返回一个安全名称。根据这个安全名称保存图像。
def extractFeatures():
img = flask.request.files["img"]
img_name = img.filename
img_secure_name = werkzeug.secure_filename(img_name)
img.save(img_secure_name)
print("Image Uploaded successfully.")
img_features = extract_features(image_path=img_secure_name)
print("Features extracted successfully.")
f = open("weights_1000_iterations_10%_mutation.pkl", "rb")
weights_mat = pickle.load(f)
f.close()
weights_mat = weights_mat[0, :]
predicted_label = predict_outputs(weights_mat, img_features, activation="sigmoid")
class_labels = ["Apple", "Raspberry", "Mango", "Lemon"]
predicted_class = class_labels[predicted_label]
return flask.render_template(template_name_or_list="result.html", predicted_class=predicted_class)
app.add_url_rule(rule="/extract", view_func=extractFeatures, methods=["POST"], endpoint="extract")
Listing 7-22Python Code for the extractFeatures View Function
上传图像到服务器后,使用清单 7-23 中定义的extract_features
函数提取其特征。它接受图像路径,并遵循第三章的 Fruits 360 数据集特征挖掘一节中的步骤,从读取图像文件,提取色调通道直方图,使用 STD 过滤特征,最后返回过滤后的特征集。基于对训练数据进行的实验,根据所选元素的索引来过滤特征。这些元素的数量是 102。然后将特征向量返回到形状为 1×102 的行数量向量中。这就为矩阵乘法做好了准备。返回特征向量后,我们可以继续执行extractFeatures
视图功能。
def extract_features(image_path):
f = open("select_indices.pkl", "rb")
indices = pickle.load(f)
f.close()
fruit_data = skimage.io.imread(fname=image_path)
fruit_data_hsv = skimage.color.rgb2hsv(rgb=fruit_data)
hist = numpy.histogram(a=fruit_data_hsv[:, :, 0], bins=360)
im_features = hist[0][indices]
img_features = numpy.zeros(shape=(1, im_features.size))
img_features[0, :] = im_features [:im_features.size]
return img_features
Listing 7-23Extracting Features from the Uploaded Image
根据清单 7-23 ,将特征向量接收到img_features
变量中的下一步是恢复使用遗传算法训练的人工神经网络所学习的一组权重。权重返回到weights_mat
变量。请注意,这些权重表示在最后一代之后返回的总体的所有解。我们只需要找到群体中的第一个解。这就是为什么索引 0 是从weights_mat
变量返回的。
根据清单 7-24 ,在准备好图像特征和学习到的权重后,下一步是将它们应用于人工神经网络,以使用predict_outputs()
函数产生预测标签。它接受权重、特征和激活函数。激活功能与我们之前实现的功能相同。predict_outputs()
函数通过一个循环,在输入和 ANN 中每层的权重之间执行矩阵乘法。到达输出层的结果后,返回预测的类索引。对应的是分数最高的班级。该索引由该函数返回。
def predict_outputs(weights_mat, data_inputs, activation="relu"):
r1 = data_inputs
for curr_weights in weights_mat:
r1 = numpy.matmul(a=r1, b=curr_weights)
if activation == "relu":
r1 = relu(r1)
elif activation == "sigmoid":
r1 = sigmoid(r1)
r1 = r1[0, :]
predicted_label = numpy.where(r1 == numpy.max(r1))[0][0]
return predicted_label
Listing 7-24Predicting the Class Label for the Uploaded Image
返回预测的类索引后,我们回到清单 7-22 。然后,返回的索引被转换成相应类的字符串标签。所有标签都保存到class_labels
列表中。预测的类标签被返回给predicted_class
变量。extractFeatures
视图函数最后使用render_template()
方法呈现result.html
模板。它将预测的类标签传递给这样的模板。清单 7-25 中提供了该模板的代码。
<html>
<header>
<title>Predicted Class</title>
<link rel="stylesheet" href="{{url_for(endpoint='static', filename='style.css')}}">
</header>
<body>
<h1>Predicted Label</h1>
<h1>{{predicted_class}}</h1>
<a href="{{url_for(endpoint='homepage')}}">Classify Another Image</a>
</body>
</html>
Listing 7-25Content of the result.html Template
该模板创建了一个表达式,能够在<h1>
元素中呈现预测的类标签。创建一个锚点,让用户返回主页对另一个图像进行分类。主页的 URL 是基于其端点返回的。打印完类别标签后的result.html
文件屏幕如图 7-21 所示。
图 7-21
对上传的图像进行分类的结果
注意,该应用只有一个名为style.css
的静态文件,根据清单 7-26 实现。它只是改变了<input>
和<a>
元素的字体大小。它还通过在文本上方和下方添加一行来为<h1>
元素的文本添加装饰。
a, input{
font-size: 30px;
color: black;
}
h1 {
text-decoration: underline overline dotted;
}
Listing 7-26Static CSS File for Adding Styles
在讨论了应用的每个部分之后,清单 7-27 中提供了完整的代码。
import flask, werkzeug, skimage.io, skimage.color, numpy, pickle
app = flask.Flask(import_name="FruitsApp")
def sigmoid(inpt):
return 1.0/(1.0+numpy.exp(-1*inpt))
def relu(inpt):
result = inpt
result[inpt<0] = 0
return result
def extract_features(image_path):
f = open("select_indices.pkl", "rb")
indices = pickle.load(f)
f.close()
fruit_data = skimage.io.imread(fname=image_path)
fruit_data_hsv = skimage.color.rgb2hsv(rgb=fruit_data)
hist = numpy.histogram(a=fruit_data_hsv[:, :, 0], bins=360)
im_features = hist[0][indices]
img_features = numpy.zeros(shape=(1, im_features.size))
img_features[0, :] = im_features[:im_features.size]
return img_features
def predict_outputs(weights_mat, data_inputs, activation="relu"):
r1 = data_inputs
for curr_weights in weights_mat:
r1 = numpy.matmul(a=r1, b=curr_weights)
if activation == "relu":
r1 = relu(r1)
elif activation == "sigmoid":
r1 = sigmoid(r1)
r1 = r1[0, :]
predicted_label = numpy.where(r1 == numpy.max(r1))[0][0]
return predicted_label
def extractFeatures():
img = flask.request.files["img"]
img_name = img.filename
img_secure_name = werkzeug.secure_filename(img_name)
img.save(img_secure_name)
print("Image Uploaded successfully.")
img_features = extract_features(image_path=img_secure_name)
print("Features extracted successfully.")
f = open("weights_1000_iterations_10%_mutation.pkl", "rb")
weights_mat = pickle.load(f)
f.close()
weights_mat = weights_mat[0, :]
predicted_label = predict_outputs(weights_mat, img_features, activation="sigmoid")
class_labels = ["Apple", "Raspberry", "Mango", "Lemon"]
predicted_class = class_labels[predicted_label]
return flask.render_template(template_name_or_list="result.html", predicted_class=predicted_class)
app.add_url_rule(rule="/extract", view_func=extractFeatures, methods=["POST"], endpoint="extract")
def homepage():
return flask.render_template(template_name_or_list="home.html")
app.add_url_rule(rule="/", view_func=homepage)
app.run(host="127.0.0.5", port=6302, debug=True)
Listing 7-27Complete Code of Flask Application for Classifying Fruits 360 Dataset Images
使用 CIFAR10 数据集部署训练模型
我们讨论的部署使用 Fruits 360 数据集训练的模型的步骤将会重复,但对于使用使用 CIFAR10 数据集训练的 TensorFlow 创建的模型。与以前的应用相比,有一些增强。应用的结构如图 7-22 所示。
图 7-22
使用 CIFAR10 数据集部署预训练模型的应用结构
我们将在后面讨论应用的每个部分。让我们从清单 7-28 中的代码开始。导入整个应用所需的库。优选在单独的模块中进行预测步骤。这就是使用CIFAR10Predict
模块的原因。它具有从 CIFAR10 数据集预测图像的类别标签所需的所有功能。这使得 Flask 应用的 Python 文件关注于视图函数。
import flask, werkzeug, os, scipy.misc, tensorflow
import CIFAR10Predict
app = flask.Flask("CIFARTF")
def redirect_upload():
return flask.render_template(template_name_or_list="upload_image.html")
app.add_url_rule(rule="/", endpoint="homepage", view_func=redirect_upload)
if __name__ == "__main__":
prepare_TF_session(saved_model_path='\\AhmedGad\\model\\')
app.run(host="localhost", port=7777, debug=True)
Listing 7-28Preparing a Flask Application for Deploying the Pretrained Model Using CIFAR10 Dataset
在运行应用之前,最好确保它是执行的主文件,而不是从另一个文件引用的。如果该文件作为主文件运行,其内部的__name__
变量将等于__main__
。否则,__name__
变量被设置为调用该文件的模块。只有当该文件是主文件时,它才应该运行。这就是使用if
语句的原因。
创建一个 TF 会话,以便使用根据清单 7-29 实现的prepare_TF_session
功能恢复预训练模型。该函数接收已保存模型的路径,以便恢复图形,并通过在进行预测之前初始化图形中的变量来准备会话。
def prepare_TF_session(saved_model_path):
global sess
global graph
sess = tensorflow.Session()
saver = tensorflow.train.import_meta_graph(saved_model_path+'model.ckpt.meta')
saver.restore(sess=sess, save_path=saved_model_path+'model.ckpt')
sess.run(tensorflow.global_variables_initializer())
graph = tensorflow.get_default_graph()
return graph
Listing 7-29Restoring the Pretrained TF Model
准备好会话后,应用以localhost
作为主机、端口号7777
和活动调试模式运行。
创建了一个将主页 URL /
绑定到视图功能redirect_upload()
的规则。这条规则有端点homepage
。当用户访问主页http://localhost:777
时,view 函数使用render_template()
方法渲染清单 7-30 中定义的upload_image.html
模板。
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" type="text/css" href="{{url_for(endpoint='static', filename='project_styles.css')}}">
<meta charset="UTF-8">
<title>Upload Image</title>
</head>
<body>
<form enctype="multipart/form-data" method="post" action="http://localhost:7777/upload/">
<center>
<h3>Select CIFAR10 image to predict its label.</h3>
<input type="file" name="image_file" accept="img/*"><br>
<input type="submit" value="Upload">
</center>
</form>
</body>
</html>
Listing 7-30HTML File for Uploading an Image from the CIFAR10 Dataset
这个 HTML 文件创建了一个表单,允许用户选择要上传到服务器的图像。该页面截图如图 7-23 所示。
图 7-23
用于上传 CIFAR10 映像的 HTML 页面的屏幕截图
这个页面非常类似于为 Fruits 360 应用创建的表单。提交表单后,数据将被发送到与由action
属性指定的规则相关联的页面,该属性是/upload
。清单 7-31 中给出了该规则及其查看功能。
def upload_image():
global secure_filename
if flask.request.method == "POST"
img_file = flask.request.files["image_file"]
secure_filename = werkzeug.secure_filename(img_file.filename
img_file.save(secure_filename)
print("Image uploaded successfully.")
return flask.redirect(flask.url_for(endpoint="predict"))
return "Image upload failed."
app.add_url_rule(rule="/upload/", endpoint="upload", view_func=upload_image, methods=["POST"])
Listing 7-31Uploading a CIFAR10 Image to the Server
/upload
规则被赋予一个名为upload
的端点,它只响应 POST 类型的 HTTP 消息。它与upload_image
视图功能相关联。它从原始文件名中检索安全文件名,并将图像保存到服务器。如果图像上传成功,那么它使用redirect()
方法将应用重定向到与predict
端点相关联的 URL。该端点属于/predict
规则。清单 7-32 中给出了规则及其视图功能。
def CNN_predict():
global sess
global graph
global secure_filename
img = scipy.misc.imread(os.path.join(app.root_path, secure_filename))
if(img.ndim) == 3:
if img.shape[0] == img.shape[1] and img.shape[0] == 32:
if img.shape[-1] == 3:
predicted_class = CIFAR10Predict.main(sess, graph, img)
return flask.render_template(template_name_or_list="prediction_result.html", predicted_class=predicted_class)
else:
return flask.render_template(template_name_or_list="error.html", img_shape=img.shape)
else:
return flask.render_template(template_name_or_list="error.html", img_shape=img.shape)
return "An error occurred."
app.add_url_rule(rule="/predict/", endpoint="predict", view_func=CNN_predict)
Listing 7-32View Function to Predict the Class Label for CIFAR10 Image
该函数读取图像文件,并根据其形状和大小检查它是否已经属于 CIFAR10 数据集。这种数据集中的每个图像都有三维;前两个维度大小相等,都是 32。此外,图像是 RGB,因此第三维具有三个通道。如果没有找到这些规范,那么应用将被重定向到根据清单 7-33 实现的error.html
模板。
<!DOCTYPE html>
<html lang="en">
<head>
<link type="text/css" rel="stylesheet" href="{{url_for(endpoint='static', filename='project_styles.css')}}">
<meta charset="UTF-8">
<title>Error</title>
</head>
<body>
<center>
<h1 class="error">Error</h1>
<h2 class="error-msg">Read image dimensions {{img_shape}} do not match the CIFAR10 specifications (32x32x3).</h2>
<a href="{{url_for(endpoint='homepage')}}"><span>Return to homepage</span>.</a>
</center>
</body>
</html>
Listing 7-33Template
for Indicating That the Uploaded Image Does Not Belong to the CIFAR10 Dataset
除了 CIFAR10 数据集的标准大小之外,它还使用表达式打印上传图像的大小。上传不同 shapeCIFAR10 数据集的图像:形状和大小,上传的图像,错误如图 7-24 所示。
图 7-24
上传与 CIFAR10 图像形状或大小不同的图像时出错
如果上传图像的形状和大小与 CIFS ar 10 图像的形状和大小相匹配,则很可能是一个 CIFS ar 10 图像,其标签将使用模块CIFAR10Predict
进行预测。如清单 7-34 所示,它有一个名为main
的函数,接受读取后的图像并返回其类标签。
def main(sess, graph, img):
patches_dir = "\\AhmedGad\\cifar-10-python\\cifar-10-batches-py\\"
dataset_array = numpy.random.rand(1, 32, 32, 3)
dataset_array[0, :, :, :] = img
softmax_propabilities = graph.get_tensor_by_name(name="softmax_probs:0")
softmax_predictions = tensorflow.argmax(softmax_propabilities, axis=1)
data_tensor = graph.get_tensor_by_name(name="data_tensor:0")
keep_prop = graph.get_tensor_by_name(name="keep_prop:0")
feed_dict_testing = {data_tensor: dataset_array, keep_prop: 1.0}
softmax_propabilities_, softmax_predictions_ = sess.run([softmax_propabilities, softmax_predictions], feed_dict=feed_dict_testing)
label_names_dict = unpickle_patch(patches_dir + "batches.meta")
dataset_label_names = label_names_dict[b"label_names"]
return dataset_label_names[softmax_predictions_[0]].decode('utf-8')
Listing 7-34Predicting the Class Label of the Image
该函数恢复所需的张量,这些张量有助于根据它们的名称返回预测标签,例如softmax_predictions
张量。一些其他张量被恢复以覆盖它们的值,它们是keep_prop
以避免在测试阶段丢失任何神经元,以及data_tensor
张量以提供上传的图像文件的数据。然后运行该会话以返回预测的标签。标签只是一个数字,是类的标识符。数据集提供了一个元数据文件,其中有一个包含所有类名称的列表。通过索引列表,标识符被转换成类字符串标签。
预测完成后,CNN_predict()
视图函数将预测的类发送给prediction_result.html
模板进行渲染。该模板的实现如清单 7-35 所示。这很简单。它只是使用一个表达式在一个<span>
元素中打印预测的类。该页面提供了基于端点返回主页的链接,以选择另一个图像进行分类。上传图片后的渲染页面如图 7-25 所示。
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" type="text/css" href="{{url_for(endpoint='static', filename='project_styles.css')}}">
<script type="text/javascript" src="{{url_for(endpoint='static', filename='result.js')}}"></script>
<meta charset="UTF-8">
<title>Prediction Result</title>
</head>
<body onload="show_alert('{{predicted_class}}')">
<center><h1>Predicted Class Label : <span>{{predicted_class}}</span></h1>
<br>
<a href="{{url_for(endpoint='homepage')}}"><span>Return to homepage</span>.</a>
</center>
</body>
</html>
Listing 7-35Rendering Predicted Class
图 7-25
预测类别标签后的渲染结果
注意,当加载清单 7-35 的元素时,有一个名为show_alert()
的 JavaScript 函数调用。它接受预测的类标签并显示警告。其实现如清单 7-36 所示。
function show_alert(predicted_class){
alert("Processing Finished.\nPredicted class is *"+predicted_class+"*.")
}
Listing 7-36JavaScript Alert Showing the Predicted Class
既然已经讨论了应用的各个部分,清单 7-37 中给出了完整的代码。
import flask, werkzeug, os, scipy.misc, tensorflow
import CIFAR10Predict#Module for predicting the class label of an input image.
#Creating a new Flask Web application. It accepts the package name.
app = flask.Flask("CIFARTF")
def CNN_predict():
"""
Reads the uploaded image file and predicts its label using the saved pretrained CNN model.
:return: Either an error if the image is not for the CIFAR10 dataset or redirects the browser to a new page to show the prediction result if no error occurred.
"""
global sess
global graph
# Setting the previously created 'secure_filename' to global.
# This is because to be able to invoke a global variable created in another function, it must be defined global in the caller function.
global secure_filename
#Reading the image file from the path it was saved in previously.
img = scipy.misc.imread(os.path.join(app.root_path, secure_filename))
# Checking whether the image dimensions match the CIFAR10 specifications.
# CIFAR10 images are RGB (i.e. they have 3 dimensions). Its number of dimensions was not equal to 3, then a message will be returned.
if(img.ndim) == 3:
# Checking if the number of rows and columns of the read image matched CIFAR10 (32 rows and 32 columns).
if img.shape[0] == img.shape[1] and img.shape[0] == 32:
# Checking whether the last dimension of the image has just 3 channels (Red, Green, and Blue).
if img.shape[-1] == 3:
# Passing all preceding conditions, the image is proved to be of CIFAR10.
# This is why it is passed to the predictor.
predicted_class = CIFAR10Predict.main(sess, graph, img)
# After predicting the class label of the input image, the prediction label is rendered on an HTML page.
# The HTML page is fetched from the /templates directory. The HTML page accepts an input which is the predicted class.
return flask.render_template(template_name_or_list="prediction_result.html", predicted_class=predicted_class)
else:
# If the image dimensions do not match the CIFAR10 specifications, then an HTML page is rendered to show the problem.
return flask.render_template(template_name_or_list="error.html", img_shape=img.shape)
else:
# If the image dimensions do not match the CIFAR10 specifications, then an HTML page is rendered to show the problem.
return flask.render_template(template_name_or_list="error.html", img_shape=img.shape)
return "An error occurred."#Returned if there is a different error other than wrong image dimensions.
# Creating a route between the URL (http://localhost:7777/predict) to a viewer function that is called after navigating to such URL.
# Endpoint 'predict' is used to make the route reusable without hard-coding it later.
app.add_url_rule(rule="/predict/", endpoint="predict", view_func=CNN_predict)
def upload_image():
"""
Viewer function that is called in response to getting to the 'http://localhost:7777/upload' URL.
It uploads the selected image to the server.
:return: redirects the application to a new page for predicting the class of the image.
"""
#Global variable to hold the name of the image file for reuse later in prediction by the 'CNN_predict' viewer functions.
global secure_filename
if flask.request.method == "POST":#Checking of the HTTP method initiating the request is POST.
img_file = flask.request.files["image_file"]#Getting the file name to get uploaded.
secure_filename = werkzeug.secure_filename(img_file.filename)#Getting a secure file name. It is a good practice to use it.
img_file.save(secure_filename)#Saving the image in the specified path.
print("Image uploaded successfully.")
# After uploading the image file successfully, next is to predict the class label of it. The application will fetch the URL that is tied to the HTML page responsible for prediction and redirects the browser to it.
# The URL is fetched using the endpoint 'predict'.
return flask.redirect(flask.url_for(endpoint="predict"))
return "Image upload failed."
# Creating a route between the URL (http://localhost:7777/upload) to a viewer function that is called after navigating to such URL.
# Endpoint 'upload' is used to make the route reusable without hard-coding it later. The set of HTTP method the viewer function is to respond to is added using the ‘methods’ argument. In this case, the function will just respond to requests of the methods of type POST.
app.add_url_rule(rule="/upload/", endpoint="upload", view_func=upload_image, methods=["POST"])
def redirect_upload():
"""
A viewer function that redirects the Web application from the root to an HTML page for uploading an image to get classified.
The HTML page is located under the /templates directory of the application.
:return: HTML page used for uploading an image. It is 'upload_image.html' in this example.
"""
return flask.render_template(template_name_or_list="upload_image.html")
# Creating a route between the homepage URL (http://localhost:7777) to a viewer function that is called after getting to such a URL.
# Endpoint 'homepage' is used to make the route reusable without hard-coding it later.
app.add_url_rule(rule="/", endpoint="homepage", view_func=redirect_upload)
def prepare_TF_session(saved_model_path):
global sess
global graph
sess = tensorflow.Session()
saver = tensorflow.train.import_meta_graph(saved_model_path+'model.ckpt.meta')
saver.restore(sess=sess, save_path=saved_model_path+'model.ckpt')
#Initializing the variables.
sess.run(tensorflow.global_variables_initializer())
graph = tensorflow.get_default_graph()
return graph
# To activate the web server to receive requests, the application must run.
# A good practice is to check whether the file is called from an external Python file or not.
# If not, then it will run.
if __name__ == "__main__":
# In this example, the app will run based on the following properties:
# host: localhost
# port: 7777
# debug: flag set to True to return debugging information.
#Restoring the previously saved trained model.
prepare_TF_session(saved_model_path='\\AhmedGad\\model\\')
app.run(host="localhost", port=7777, debug=True)
Listing 7-37Complete Flask Application for CIFAR10 Dataset
八、跨平台数据科学应用
当前的 DL 库中有一些版本支持为移动设备构建应用。例如,TensorFlowLite、Caffe Android 和 Torch Android 都是分别来自 TF、Caffe 和 Torch 的版本,以支持移动设备。这些释放是基于他们的父母。为了使原始模型在移动设备上工作,必须有一个中间步骤。例如,创建使用 TensorFlowLite 的 Android 应用的过程有以下总结步骤:
-
准备 TF 模型。
-
将 TF 模型转换为 TensorFlowLite 模型。
-
创建一个 Android 项目。
-
在项目中导入 TensorFlowLite 模型。
-
在 Java 代码中调用模型。
为构建一个适合在移动设备上运行的模型而经历这些步骤是令人厌倦的。具有挑战性的步骤是第二步。
TensorFlowLite 是与移动设备兼容的版本。因此,与它的祖先 TF 相比,它被简化了。这意味着它不支持其父库中的所有内容。到目前为止,TensorFlowLite 还不支持 TF 中的一些操作,如 tanh、image.resize_bilinear 和 depth_to_space。当准备在移动设备上工作的模型时,这增加了限制。此外,模型开发人员必须使用语言来创建运行经过训练的 CNN 模型的 Android 应用。使用 Python,将使用 TF 创建模型。在使用 TF 优化转换器(TOCO)优化模型之后,使用 Android Studio 创建一个项目。在这样的项目中,将使用 Java 调用模型。因此,这个过程并不简单,创建应用也很有挑战性。有关使用 TensorFlowLite 构建移动应用的更多信息,请阅读此链接的文档( www.tensorflow.org/lite/overview
)。在这一章中,我们将使用 Kivy (KV)以最小的努力构建跨平台运行的应用。
Kivy 是一个抽象和模块化的开源跨平台 Python 框架,用于创建自然用户界面(ui)。它通过使用后端库对图形硬件进行低级访问并处理音频和视频,将开发人员从复杂的细节中分离出来。它只是为开发人员提供简单的 API 来完成任务。
本章使用一些简单的例子来介绍 Kivy,帮助解释它的基本程序结构、UI 小部件、使用 KV 语言构造小部件以及处理动作。Kivy 支持在 Window、Linux、Mac 以及移动设备上执行相同的 Python 代码,这使得它具有跨平台性。使用 Buildozer 和 Python-4-Android (P4A),Kivy 应用被转换成一个 Android 包。不仅执行原生 Python 代码;Kivy 还支持一些在移动设备上执行的库,比如 NumPy 和 PIL (Python Image Library)。在本章结束时,使用 NumPy 构建了一个跨平台应用来执行第五章中实现的 CNN。本章使用 Ubuntu 是因为 Buildozer 目前可以在 Linux 上使用。
Kivy 简介
在本节中,将基于一些示例详细讨论 Kivy 基础知识。这有助于我们着手构建自己的应用。记得从第七章开始,Flask 应用通过实例化 Flask 类开始创建应用;然后应用通过调用run()
方法来运行。Kivy 类似,但有一些变化。我们可以假设Flask
类对应于 Kivy 中的App
类。Kivy 和 Flask 内部都有一个叫run()
的方法。Kivy 应用不是通过实例化 App 类创建的,而是通过实例化一个扩展 App 类的子类创建的。然后,应用通过使用从子类创建的实例调用run()
方法来运行。
Kivy 用于构建一个 UI,该 UI 由一组称为小部件的可视元素组成。在实例化类和运行它之间,我们必须指定使用哪些小部件以及它们的布局。App 类支持一个名为build()
的方法,该方法返回包含 UI 中所有其他小部件的布局小部件。可以从父 App 类中重写此方法。
使用 BoxLayout 的基本应用
让我们通过讨论清单 8-1 中的一个基本 Kivy 应用来让事情变得更清楚。首先,从 Kivy 导入所需的模块。kivy.app
包含了 App 类。这个类被用作我们定义的类FirstApp
的父类。第二个语句导入kivy.uix.label
,它有一个标签小部件。这个小部件只在 UI 上显示文本。
在build()
方法中,标签小部件是使用kivy.uix.label.Label
类创建的。该类构造函数接受一个名为text
的参数,它是要在 UI 上显示的文本。返回的标签保存为FirstApp
对象的属性。与将小部件保存在单独的变量中相比,将小部件作为属性添加到类对象中可以更容易地在以后检索它们。
import kivy.app
import kivy.uix.label
import kivy.uix.boxlayout
class FirstApp(kivy.app.App):
def build(self):
self.label = kivy.uix.label.Label(text="Hello Kivy")
self.layout = kivy.uix.boxlayout.BoxLayout()
self.layout.add_widget(widget=self.label)
return self.layout
firstApp = FirstApp()
firstApp.run()
Listing 8-1Basic Kivy Application
Kivy 中的小部件被分组到一个根小部件中。在清单 8-1 中,BoxLayout
被用作根小部件,它包含所有其他小部件。这就是为什么kivy.uix.boxlayout
是进口的。基于kivy.uix.label.BoxLayout
类的构造函数,BoxLayout
对象被保存为FirstClass
对象的属性。创建标签和布局对象后,使用add_widget()
方法将标签添加到布局中。这个方法有一个名为widget
的参数,它接受要添加到布局中的小部件。将标签添加到根小部件(布局)后,布局由build()
方法返回。
在创建了子类FirstApp
并准备好它的build()
方法之后,就创建了该类的一个实例。然后该实例调用run()
方法,应用窗口根据图 8-1 显示。
图 8-1
带有文本标签的简单 Kivy 应用
Kivy 应用生命周期
只需运行应用,build()
方法中定义的小部件就会呈现在屏幕上。请注意,Kivy 生命周期如图 8-2 所示。它类似于 Android 应用的生命周期。生命周期从使用run()
方法运行应用开始。之后,执行build()
方法,返回要显示的小部件。执行on_start()
方法后,应用成功运行。此外,应用可能会暂停或停止。如果暂停了,那么调用on_pause()
方法。如果应用恢复,那么调用on_resume()
方法。如果没有恢复,应用就会停止。应用可能会在没有暂停的情况下直接停止。如果是这种情况,就调用on_stop()
方法。
图 8-2
Kivy 应用生命周期
图 8-1 顶部的标题有First
字样。那是什么?子类被命名为FirstApp
。当类以单词App
结尾命名时,Kivy 使用它前面的工作作为应用标题。给班级取名MyApp
,那么题目就是My
。注意App
这个词必须以大写字母开头。如果类被命名为Firstapp
,那么标题也将是Firstapp
。注意,我们能够使用类构造函数的title
参数来设置自定义名称。构造函数还接受一个名为icon
的参数,它是一个图像的路径。
清单 8-2 将应用标题设置为自定义标题,并且还实现了on_start()
和on_stop()
方法。窗口如图 8-3 所示。当应用启动时,调用on_start()
方法来打印消息。对于on_stop()
方法也是如此。
图 8-3
改变应用标题
import kivy.app
import kivy.uix.label
import kivy.uix.boxlayout
class FirstApp(kivy.app.App):
def build(self):
self.label = kivy.uix.label.Label(text="Hello Kivy")
self.layout = kivy.uix.boxlayout.BoxLayout()
self.layout.add_widget(widget=self.label)
return self.layout
def on_start(self):
print("on_start()")
def on_stop(self):
print("on_stop()")
firstApp = FirstApp(title="First Kivy Application.")
firstApp.run()
Listing 8-2Implementing Life Cycle Methods
我们可以在BoxLayout
中添加多个小部件。这个布局小部件垂直或水平排列其子部件。它的构造函数有一个名为orientation
的参数来定义排列。它有两个值:horizontal
和vertical
。默认为horizontal
。
如果方向设置为垂直,则小部件堆叠在彼此之上,其中第一个添加的小部件出现在窗口的底部,最后一个添加的小部件出现在顶部。在这种情况下,窗口高度在所有子小部件中平均分配。
如果方向是水平的,那么小部件是并排添加的,其中第一个添加的小部件是屏幕上最左边的小部件,而最后一个添加的小部件是屏幕上最右边的小部件。在这种情况下,窗口的宽度在所有子部件之间平均分配。
清单 8-3 使用了五个按钮部件,它们的文本设置为Button 1
、Button 2
,直到Button 5
。这些小部件被水平添加到一个BoxLayout
小部件中。结果如图 8-4 所示。
图 8-4
BoxLayout
小工具的水平方向
import kivy.app
import kivy.uix.button
import kivy.uix.boxlayout
class FirstApp(kivy.app.App):
def build(self):
self.button1 = kivy.uix.button.Button(text="Button 1")
self.button2 = kivy.uix.button.Button(text="Button 2")
self.button3 = kivy.uix.button.Button(text="Button 3")
self.button4 = kivy.uix.button.Button(text="Button 4")
self.button5 = kivy.uix.button.Button(text="Button 5")
self.layout = kivy.uix.boxlayout.BoxLayout(orientation="horizontal")
self.layout.add_widget(widget=self.button1)
self.layout.add_widget(widget=self.button2)
self.layout.add_widget(widget=self.button3)
self.layout.add_widget(widget=self.button4)
self.layout.add_widget(widget=self.button5)
return self.layout
firstApp = FirstApp(title="Horizontal BoxLayout Orientation.")
firstApp.run()
Listing 8-3Kivy Application using BoxLayout as the Root Widget with Horizontal Orientation
小部件大小
BoxLayout
将屏幕平均分配给所有的小工具。添加五个小部件,然后它将屏幕在宽度和高度上分成五个相等的部分。它给每个部件分配一个大小相等的部分。我们可以使用小部件的size_hint
参数使分配给小部件的零件尺寸变大或变小。它接受一个具有两个值的元组,这两个值定义了相对于窗口大小的宽度和高度。默认情况下,所有小部件的元组都是(1,1)。这意味着大小相等。如果小部件的此参数设置为(2,1),则小部件的宽度将是默认宽度的两倍。如果设置为(0.5,1),那么小部件宽度将是默认宽度的一半。
清单 8-4 更改了一些小部件的size_hint
参数。图 8-5 显示了每个按钮的文本反映其相对于窗口大小的宽度的结果。请注意,小部件向父小部件发出提示,希望其大小符合由size_hint
参数指定的值。家长可以接受或拒绝请求。这就是为什么它的参数名中有hint
这个词。例如,设置小部件的col_force_default
或row_force_default
属性会使父部件完全忽略size_hint
参数。注意,size_hint
是小部件构造函数的一个参数,也可以作为小部件实例的一个属性。
图 8-5
使用 size_hint 参数改变小部件的宽度
import kivy.app
import kivy.uix.button
import kivy.uix.boxlayout
class FirstApp(kivy.app.App):
def build(self):
self.button1 = kivy.uix.button.Button(text="2", size_hint = (2, 1))
self.button2 = kivy.uix.button.Button(text="1")
self.button3 = kivy.uix.button.Button(text="1.5", size_hint = (1.5, 1))
self.button4 = kivy.uix.button.Button(text="0.7", size_hint = (0.7, 1))
self.button5 = kivy.uix.button.Button(text="3", size_hint = (3, 1))
self.layout = kivy.uix.boxlayout.BoxLayout(orientation="horizontal")
self.layout.add_widget(widget=self.button1)
self.layout.add_widget(widget=self.button2)
self.layout.add_widget(widget=self.button3)
self.layout.add_widget(widget=self.button4)
self.layout.add_widget(widget=self.button5)
return self.layout
firstApp = FirstApp(title="Horizontal BoxLayout Orientation.")
firstApp.run()
Listing 8-4Using the size_hint Argument with theIf added “with” not OK, please clarify listing caption. Widgets to Change Their Relative
Size
网格布局
也有BoxLayout
以外的布局。例如,GridLayout
根据指定的行数和列数将屏幕分成网格。根据清单 8-5 ,创建了一个两行三列的网格布局,其中添加了六个按钮。行数和列数分别根据rows
和cols
属性设置。添加的第一个小部件出现在左上角,而添加的最后一个小部件出现在右下角。结果如图 8-6 所示。
图 8-6
两行三列的网格布局
import kivy.app
import kivy.uix.button
import kivy.uix.gridlayout
class FirstApp(kivy.app.App):
def build(self):
self.button1 = kivy.uix.button.Button(text="Button 1")
self.button2 = kivy.uix.button.Button(text="Button 2")
self.button3 = kivy.uix.button.Button(text="Button 3")
self.button4 = kivy.uix.button.Button(text="Button 4")
self.button5 = kivy.uix.button.Button(text="Button 5")
self.button6 = kivy.uix.button.Button(text="Button 6")
self.layout = kivy.uix.gridlayout.GridLayout(rows=2, cols=3)
self.layout.add_widget(widget=self.button1)
self.layout.add_widget(widget=self.button2)
self.layout.add_widget(widget=self.button3)
self.layout.add_widget(widget=self.button4)
self.layout.add_widget(widget=self.button5)
self.layout.add_widget(widget=self.button6)
return self.layout
firstApp = FirstApp(title="GridLayout with 2 rows and 3 columns.")
firstApp.run()
Listing 8-5Dividing the Window into a Grid of Size 2×3 Using GridLayout
另一种适合移动设备的布局是PageLayout
。它实际上在同一个布局中构建了几个页面。在页面边框处,用户可以向左或向右拖动页面,以便导航到另一个页面。创建这样的布局很简单。只需创建一个kivy.uix.pagelayout.PageLayout
类的实例,这类似于我们之前所做的。然后,将小部件添加到布局中,就像我们使用add_widget()
方法一样。
更多小部件
UI 中有多个小部件可以使用。例如,Image
小部件用于根据图像的来源显示图像。TextInput
小部件允许用户将输入输入到应用中。其他还有CheckBox
、RadioButton
、Slider
等等。
清单 8-6 给出了一个带有Button
、Label
、TextInput
和Image
小部件的例子。TextInput
类的构造函数有一个名为hint_text
的属性,它在小部件中显示一条提示消息,帮助用户知道要输入什么。image 小部件使用source
属性来指定图像路径。图 8-7 显示了结果。稍后,我们将处理这些小部件的动作,比如按钮点击、改变标签文本等等。
图 8-7
带有Label
、TextInput
、Button
和Image
小部件的垂直BoxLayout
import kivy.app
import kivy.uix.label
import kivy.uix.textinput
import kivy.uix.button
import kivy.uix.image
import kivy.uix.boxlayout
class FirstApp(kivy.app.App):
def build(self):
self.label = kivy.uix.label.Label(text="Label")
self.textinput = kivy.uix.textinput.TextInput(hint_text="Hint Text")
self.button = kivy.uix.button.Button(text="Button")
self.image = kivy.uix.image.Image(source="im.png")
self.layout = kivy.uix.boxlayout.BoxLayout(orientation="vertical")
self.layout.add_widget(widget=self.label)
self.layout.add_widget(widget=self.textinput)
self.layout.add_widget(widget=self.button)
self.layout.add_widget(widget=self.image)
return self.layout
firstApp = FirstApp(title="BoxLayout with Label, Button, TextInput, and Image")
firstApp.run()
Listing 8-6BoxLayout with Label, TextInput, Button, and Image Widgets
小部件树
在前面的例子中,有一个根小部件(布局),有几个子部件直接连接到它。列表 8-6 的 widget 树如图 8-8 所示。这棵树只有一层。我们可以创建一个更深的树,如图 8-9 所示,其中垂直方向的根BoxLayout
部件有两个子布局。第一个是一个有两行两列的GridLayout
小部件。第二个子窗口是水平方向的水平BoxLayout
窗口小部件。这些子GridLayout
部件有自己的子部件。
图 8-9
具有嵌套布局的小部件树
图 8-8
清单 8-6 中 Kivy 应用的部件树
清单 8-7 中给出了带有图 8-9 中定义的小部件树的 Kivy 应用。应用创建每个父节点,然后创建其子节点,最后将这些子节点添加到父节点中。该应用的渲染窗口如图 8-10 所示。
图 8-10
嵌套小部件
import kivy.app
import kivy.uix.label
import kivy.uix.textinput
import kivy.uix.button
import kivy.uix.image
import kivy.uix.boxlayout
import kivy.uix.gridlayout
class FirstApp(kivy.app.App):
def build(self):
self.gridLayout = kivy.uix.gridlayout.GridLayout(rows=2, cols=2)
self.image1 = kivy.uix.image.Image(source="apple.jpg")
self.image2 = kivy.uix.image.Image(source="bear.jpg")
self.button1 = kivy.uix.button.Button(text="Button 1")
self.button2 = kivy.uix.button.Button(text="Button 2")
self.gridLayout.add_widget(widget=self.image1)
self.gridLayout.add_widget(widget=self.image2)
self.gridLayout.add_widget(widget=self.button1)
self.gridLayout.add_widget(widget=self.button2)
self.button3 = kivy.uix.button.Button(text="Button 3")
self.button4 = kivy.uix.button.Button(text="Button 4")
self.boxLayout = kivy.uix.boxlayout.BoxLayout(orientation="horizontal")
self.textinput = kivy.uix.textinput.TextInput(hint_text="Hint Text.")
self.button5 = kivy.uix.button.Button(text="Button 5")
self.boxLayout.add_widget(widget=self.textinput)
self.boxLayout.add_widget(widget=self.button5)
self.rootBoxLayout = kivy.uix.boxlayout.BoxLayout(orientation="vertical")
self.rootBoxLayout.add_widget(widget=self.gridLayout)
self.rootBoxLayout.add_widget(widget=self.button3)
self.rootBoxLayout.add_widget(widget=self.button4)
self.rootBoxLayout.add_widget(widget=self.boxLayout)
return self.rootBoxLayout
firstApp = FirstApp(title="Nested Widgets.")
firstApp.run()
Listing 8-7Kivy Application with Nested Widgets in the Widget Tree
处理事件
我们可以使用bind()
方法处理 Kivy 小部件生成的事件。此方法接受指定要处理的目标事件的参数。该参数被赋予一个函数或方法,用于处理此类事件。例如,当按下按钮时,触发on_press
事件。因此,bind()
方法使用的参数将被命名为on_press
。假设我们想要使用一个叫做handle_press
的方法来处理这个事件,那么bind()
方法的on_press
参数将被赋予这个方法名。请注意,处理事件的方法接受一个参数,该参数表示触发事件的小部件。让我们看看清单 8-8 中的应用是如何工作的。
这个应用有两个TextInput
小部件,一个Label
和一个Button
。用户在每个TextInput
小部件中输入一个数字。当按钮被按下时,数字被取出并相加,然后结果被呈现在Label
上。基于前面的例子,应用中的一切都是我们熟悉的,除了调用bind()
方法来使用add_nums()
方法处理 press 事件。
import kivy.app
import kivy.uix.label
import kivy.uix.textinput
import kivy.uix.button
import kivy.uix.image
import kivy.uix.boxlayout
import kivy.uix.gridlayout
class FirstApp(kivy.app.App):
def add_nums(self, button):
num1 = float(self.textinput1.text)
num2 = float(self.textinput2.text)
result = num1 + num2
self.label.text = str(result)
def build(self):
self.boxLayout = kivy.uix.boxlayout.BoxLayout(orientation="horizontal")
self.textinput1 = kivy.uix.textinput.TextInput(hint_text="Enter First Number.")
self.textinput2 = kivy.uix.textinput.TextInput(hint_text="Enter Second Number.")
self.boxLayout.add_widget(widget=self.textinput1)
self.boxLayout.add_widget(widget=self.textinput2)
self.label = kivy.uix.label.Label(text="Result of Addition.")
self.button = kivy.uix.button.Button(text="Add Numbers.")
self.button.bind(on_press=self.add_nums)
self.rootBoxLayout = kivy.uix.boxlayout.BoxLayout(orientation="vertical")
self.rootBoxLayout.add_widget(widget=self.label)
self.rootBoxLayout.add_widget(widget=self.boxLayout)
self.rootBoxLayout.add_widget(widget=self.button)
return self.rootBoxLayout
firstApp = FirstApp(title="Handling Actions using Bind().")
firstApp.run()
Listing 8-8Application for Adding Two Numbers and Showing Their Results on a Label
按钮调用bind()
方法,这是任何小部件的属性。为了处理on_press
事件,该方法将使用它作为一个参数。该参数被设置为等于用名称add_nums
创建的自定义函数。这意味着每次触发on_press
事件时,都会执行add_nums()
方法。on_press
本身就是一个方法。因为默认情况下它是空的,所以我们需要给它添加一些逻辑。那个逻辑可能是我们在 Python 文件中定义的方法,比如add_nums
方法。注意,我们创建了一个方法,而不是一个处理事件的函数来访问对象中的所有小部件。如果使用了函数,那么我们必须传递处理事件所需的小部件的属性。
在add_nums()
方法中,使用text
属性将两个TextInput
小部件中的文本返回到num1
和num2
变量中。因为text
属性返回的结果是一个字符串,所以我们要把它转换成一个数字。这是使用float()
功能完成的。两个数相加,结果返回到result
变量。将两个数相加将返回一个数。因此,result
变量的数据类型是数字。因为text
属性只接受字符串,我们必须使用str()
函数将result
变量转换成字符串,以便在标签上显示它的值。图 8-11 显示了将两个数相加并将结果呈现在Label
小工具上后的应用 UI。
图 8-11
将两个数字相加并在Label
小部件上显示结果的应用 UI
KV 语言
通过添加更多小部件来扩大小部件树会使 Python 代码更难调试。类似于我们在第七章中所做的,将 HTML 代码从 Flask 应用的逻辑中分离出来,在这一章中,我们将把 UI 代码从应用逻辑中分离出来。
UI 将使用一种叫做 KV language (kvlang 或 Kivy language)的语言创建。这种语言创建扩展名为.kv
的文件来保存 UI 小部件。因此,将有一个用于处理事件等应用逻辑的.py
文件,以及另一个用于保存应用 UI 的.kv
文件。KV 语言以一种简单的方式构建小部件树,与将它添加到 Python 代码中相比,这种方式更容易理解。KV 语言使得调试 UI 变得容易,因为它清楚地表明了给定的父节点属于哪个子节点。
KV 文件由一组规则组成,这些规则类似于定义小部件的 CSS 规则。规则由小部件类和一组属性及其值组成。在小部件类名后添加一个冒号,表示小部件内容的开始。给定小部件下的内容是缩进的,就像 Python 定义块的内容一样。属性名与其值之间有一个冒号。例如,清单 8-9 创建了一个构建按钮小部件的规则。
按钮小部件后跟一个冒号。冒号后缩进的所有内容都属于该小部件。缩进空间的数量并不固定为四个。它类似于 Python,我们可以使用任意数量的空格。我们发现有三个属性是缩进的。第一个是text
属性,它使用冒号与值分开。转到一个新的缩进行,我们可以写入新的属性background_color
,使用冒号将其与值分开。顺便说一下,颜色是使用 RGBA 颜色空间定义的,其中 A 表示 alpha 通道。颜色值介于 0.0 和 1.0 之间。对于第三个属性,重复相同的过程,用冒号将它的名称和值分开。color
属性定义了文本的颜色。
Button:
text: "Press Me."
background_color: (0.5, 0.5, 0.5, 1.0)
color: (0,0,0,1)
Listing 8-9Preparing the Button Widget with Some Properties Using KV Language
我们可以创建一个简单的 Kivy 应用,它使用 KV 文件来构建 UI。假设我们想要构建一个 UI,以BoxLayout
小部件作为垂直方向的根。这个根小部件有三个子部件(Button
、Label
和TextInput
)。注意,KV 语言只有一个根小部件,它是通过不加任何缩进地键入来定义的。这个根小部件的子部件将被同等缩进。清单 8-10 中给出了 KV 语言文件。在根小部件之后,Button
、Label
和TextInput
小部件缩进四个空格。根小部件本身可以有属性。每个子部件的属性都缩进在它们的部件后面。这很简单,但是我们如何在 Python 代码中使用这个 KV 文件呢?
BoxLayout:
orientation: "vertical"
Button:
text: "Press Me."
color: (1,1,1,1)
Label:
text: "Label"
TextInput:
hint_text: "TextInput"
Listing 8-10Simple UI Created Using KV Language
在 Python 代码中加载 KV 文件有两种方式。第一种方法是在kivy.lang.builder.Builder
类的load_file()
方法中指定文件的路径。这个方法使用它的filename
参数来指定文件的路径。该文件可以位于任何位置,不需要与 Python 文件位于同一目录中。清单 8-11 展示了如何以这种方式定位 KV 文件。
以前,build()
方法的返回是在 Python 文件中定义的根小部件。现在它返回load_file()
方法的结果。将 Python 文件中的逻辑与表示分离后,Python 代码更加清晰,现在表示在 KV 文件中。
import kivy.app
import kivy.lang.builder
class FirstApp(kivy.app.App):
def build(self):
return kivy.lang.builder.Builder.load_file(filename='ahmedgad/FirstApp/first.kv')
firstApp = FirstApp(title="Importing UI from KV File.")
firstApp.run()
Listing 8-11Locating the LV File Using Its Path
使用第二种加载 KV 文件的方式可以使代码更加清晰。这种方式依赖于继承 App 类的子类的名称。如果这个类被命名为FirstApp
,那么 Kivy 将寻找一个名为first.kv
的 KV 文件。也就是说,App
这个词被删除,剩下的文本First
被转换成小写。如果 Python 文件所在的目录下有一个名为first.kv
的文件,那么这个文件将被自动加载。
当使用这个方法时,Python 代码将如清单 8-12 所示。代码现在比以前更清晰,调试也更简单。在FirstApp
类中添加了pass
语句,以避免让它为空。注意,如果 Kivy 找不到根据first.kv
命名的文件,应用仍将运行,但会显示一个空白窗口。
import kivy.app
class FirstApp(kivy.app.App):
pass
firstApp = FirstApp(title="Importing UI from KV File.")
firstApp.run()
Listing 8-12Loading the KV File Named According to the Child Class Name
我们可以将清单 8-8 中的 UI 从 Python 代码中分离出来,并将事件处理程序绑定到 KV 文件中的按钮。KV 文件在清单 8-13 中给出。
还有几点值得一提。可以使用id
属性在 KV 文件中给小部件一个 ID。它的值不需要用引号括起来。ID 可用于检索 KV 文件和 Python 文件中的小部件的属性。根据代码,id 被赋予元素Label
和两个TextInput
小部件。原因是这些是我们希望根据其属性检索或更改的小部件。
BoxLayout:
orientation: "vertical"
Label:
text: "Result of Addition."
id: label
BoxLayout:
orientation: "horizontal"
TextInput:
hint_text: "Enter First Number."
id: textinput1
TextInput:
hint_text: "Enter Second Number."
id: textinput2
Button:
text: "Add Numbers."
on_press: app.add_nums(root)
Listing 8-13UI of Listing 8-8 for Adding Two Numbers Separated into KV File
按钮小部件具有on_press
属性。它用于将事件处理程序绑定到on_press
事件。事件处理程序是清单 8-14 中 Python 代码内的add_nums()
方法。因此,我们想从 KV 文件中调用一个 Python 方法。我们如何做到这一点?
KV 语言有三个很有帮助的关键词:app
,指应用实例;root
,指 KV 文件中的根 widget 还有self
,指的是当前的小部件。从 Python 代码中调用方法的合适的关键字是app
关键字。因为它引用了整个应用,所以它将能够引用 Python 文件中的方法。因此,我们可以用它来调用使用app.add_nums()
的add_nums()
方法。
import kivy.app
class FirstApp(kivy.app.App):
def add_nums(self, root):
num1 = float(self.root.ids["textinput1"].text)
num2 = float(self.root.ids["textinput2"].text)
result = num1 + num2
self.root.ids["label"].text = str(result)
firstApp = FirstApp(title="Importing UI from KV File.")
firstApp.run()
Listing 8-14Kivy Python File for Handling the on_press Event
在这个方法中,我们想要引用TextInput
和 label 小部件,以便获取输入的数字并在标签上打印结果。因为self
参数指的是调用它的对象,也就是关于整个应用的实例,所以我们可以用self.root
用它来指根小部件。这将返回小部件的根,可用于根据它们的 id 访问它的任何子小部件。
KF 文件中的所有 id 都保存在ids
字典中。我们可以使用这个字典来检索我们想要的任何小部件,只要它有一个 ID。在检索小部件本身之后,我们可以获取它的属性。这样,我们可以返回在TextInput
小部件中输入的数字,将它们的值从字符串转换为浮点,将它们相加,并将转换为字符串后的结果赋给Label
小部件的text
属性。
P4A
至此,我们对 Kivy 有了一个很好的概述。我们可以使用 Kivy 构建 Android 应用。我们将从打包清单 8-13 和清单 8-14 中的 Kivy 应用开始。
之前的应用不做任何改动,打包后就可以在 Android 上运行了。将 Kivy 应用转换为 Android 应用的简化步骤如图 8-12 所示。
图 8-12
从 Kivy 应用构建 Android 应用的步骤
完成 Kivy Python 应用后,Buildozer 工具将准备创建 APK 文件所需的工具。最重要的工具叫做 P4A。Buildozer 工具在转换成 Android 应用之前为每个 Kivy 应用创建一个名为buildozer.spec
的文件。该文件保存了应用的详细信息,稍后将在第节准备 buildozer.spec 文件中讨论。让我们从安装 Buildozer 工具开始。
安装推土机
本节使用 Buildozer 工具将 Kivy 应用打包成 Android 应用。安装完成后,Buildozer 会自动完成构建 Android 应用的过程。它根据所有需求准备环境,以便成功地构建应用。这些需求包括 P4A、Android SDK 和 NDK。在安装 Buildozer 之前,需要一些依赖项。可以使用以下 Ubuntu 命令自动下载和安装它们:
ahmed-gad@ubuntu:~$ sudo pip install --upgrade cython==0.21
ahmed-gad@ubuntu:~$ sudo dpkg --add-architecture i386
ahmed-gad@ubuntu:~$ sudo apt-get update
ahmed-gad@ubuntu:~$ sudo apt-get install build-essential ccache git libncurses5:i386 libstdc++6:i386 libgtk2.0-0:i386 libpangox-1.0-0:i386 libpangoxft-1.0-0:i386 libidn11:i386 python2.7 python2.7-dev openjdk-8-jdk unzip zlib1g-dev zlib1g:i386
成功安装这些依赖项后,可以根据以下命令安装 Buildozer:
ahmed-gad@ubuntu:~$ sudo install --upgrade buildozer
如果您的机器上当前安装了 Buildozer,那么- upgrade 选项可以确保它被升级到最新版本。成功安装 Buildozer 后,让我们准备好buildozer.spec
文件,以便构建 Android 应用。
正在准备 buildozer.spec 文件
图 8-13 给出了要打包成 Android 应用的项目结构。有一个文件夹名为FirstApp
,里面有三个文件。第一个文件名为main.py
,这是之前名为FirstApp.py
的 Kivy 应用。之所以改名,是因为在构建 Android 应用的时候,必须有一个名为main.py
的文件,它是应用的入口。这不会改变应用中的任何内容。
图 8-13
项目结构
在继续下一步之前,最好检查 Kivy 应用是否运行成功。只需在你的机器上激活 Kivy 虚拟环境,并根据图 8-14 运行main.py
Python 文件。预计其工作方式如图 8-11 所示。
图 8-14
激活 Kivy 虚拟环境以运行 Kivy 应用
至此,已经成功创建了一个 Kivy 桌面应用。我们现在可以开始准备丢失的文件buildozer.spec
并构建一个 Android 应用。
使用 Buildozer 可以简单地自动生成buildozer.spec
文件。打开 Ubuntu 终端并导航到应用 Python 和 KV 文件所在的FirstApp
目录后,发出以下命令:
ahmed-gad@ubuntu:~/ahmedgad/FirstApp$ buildozer init
发出该命令后,出现确认信息,如图 8-15 所示。该文件的一些重要字段在清单 8-15 中列出。例如,title
代表应用标题;source
目录是指main.py
文件所在的应用的根目录,这里设置为当前目录;app 版本;Python 和 Kivy 版本;orientation
,即应用是否全屏出现;和应用requirements
,这只是设置为 kivy。如果我们使用 P4A 支持的库,比如 NumPy,那么我们需要将它列在 kivy 旁边,以便将其加载到应用中。permissions
属性表示应用请求的权限。如果您的计算机上已经存在 SKD 和 NDK 的路径,您也可以对它们进行硬编码,以节省下载时间。注意,行前的#
字符表示它是一个注释。presplash.filename
属性用于指定启动前加载应用时出现的图像路径。icon.filename
属性被赋予用作应用图标的图像的文件名。
图 8-15
成功创建buildozer.spec
文件
这些字段位于规范文件的[app]部分。您还可以编辑规范文件,以更改您认为值得修改的任何字段。默认情况下,package.domain
属性被设置为org.test
,这仅用于测试,不用于生产。如果该值保持不变,它将阻止应用的构建。
[app]
title = Simple Application
package.name = firstapp
package.domain = gad.firstapp
source.dir = .
source.include_exts = py,png,jpg,kv,atlas
version = 0.1
requirements = kivy
orientation = portrait
osx.python_version = 3
osx.kivy_version = 1.10.1
fullscreen = 0
presplash.filename = presplash.png
icon.filename = icon.png
android.permissions = INTERNET
android.api = 19
android.sdk = 20
android.ndk = 9c
android.private_storage = True
#android.ndk_path =
#android.sdk_path =
Listing 8-15Some Important Fields from the buildozer.spec File
在准备好构建 Android 应用所需的文件之后,下一步是使用 Buildozer 构建它。
使用推土机构建 android 应用
准备好所有的项目文件后,Buildozer 使用它们来生成 APK 文件。对于开发,我们可以使用以下命令生成应用的调试版本:
ahmed-gad@ubuntu:~/ahmedgad/FirstApp$ buildozer android release
图 8-16 显示了输入命令时的响应。第一次构建应用时,Buildozer 必须下载所有必需的依赖项,比如 SDK、NDK 和 P4A。Buildozer 通过自动下载和安装它们节省了很多精力。根据您的互联网连接,该过程可能需要一段时间才能全部启动和运行;耐心点。
图 8-16
安装 Buildozer 构建 Android 应用所需的依赖项
安装成功完成后,会创建两个文件夹。第一个名为.buildozer
;它表示 Buildozer 下载的构建应用所需的所有文件。第二个文件夹名为bin
;它存储构建应用后生成的 APK 文件。我们可以将 APK 文件转移到 Android 设备上进行安装和测试。Android 应用的屏幕如图 8-17 所示。
图 8-17
运行 Android 应用
如果机器连接并识别了一个 Android 设备,Buildozer 可以根据以下命令生成 APK 文件并在机器上安装它:
ahmed-gad@ubuntu:~/ahmedgad/FirstApp$ buildozer android debug deploy run
在基于 Python Kivy 应用构建了基本的 Android 应用之后,我们可以开始构建更高级的应用。并非所有运行在桌面上的 Kivy 应用都可以直接在移动设备上运行。某些库可能不支持打包到移动应用中。例如,P4A 只支持一组可以在 Android 应用中使用的库。如果您使用了不支持的库,应用会崩溃。
P4A 支持 Kivy,它可以构建与我们之前讨论的完全一样的应用 UI。P4A 还支持其他库,如NumPy
、PIL
、dateutil
、OpenCV
、Pyinius
、Flask
等等。使用 Python 构建 Android 应用的限制是只能使用 P4A 支持的库集。在下一节中,我们将讨论如何从第三章中创建的应用构建一个 Android 应用,用于识别水果 360 数据集图像。
Android 上的图像识别
第三章创建的应用从 Fruits 360 数据集提取特征,用于训练人工神经网络。在第七章中,创建了一个 Flask 应用来从网络上访问它。在这一章中,我们将讨论如何将它打包到一个离线运行的 Android 应用中,并在设备上提取功能。
首先要考虑的是这个应用中使用的库是否受 P4A 支持。使用的库如下:
-
scikit-image
用于读取原始 RGB 图像并将其转换为 HSV。 -
NumPy
用于提取特征(即色调直方图),构建人工神经网络层,并进行预测。 -
pickle
用于恢复使用遗传算法和所选特征元素的指数训练的网络的最佳权重。
从使用的库中,P4A 只支持 NumPy。不支持scikit-image
和pickle
。因此,我们必须找到 P4A 支持的替代库来取代这两个库。替换scikit-image
的选项有OpenCV
和PIL
。我们只需要一个库来读取图像文件,并将其转换为 HSV,仅此而已。OpenCV
比所需的两个功能更多。将这个库打包到 Android 应用中会增加它的大小。为此,使用PIL
是因为它更简单。
关于pickle
,我们可以用NumPy
来代替。NumPy
可以在扩展名为.npy
的文件中保存和加载变量。因此,权重和所选元素指数将保存到.npy
文件中,以便使用NumPy
读取。
项目结构如图 8-18 所示。Fruits.py
文件包含从测试图像中提取特征并预测其标签所需的函数。除了使用NumPy
而不是pickle
和PIL
而不是scikit-image
之外,这些功能与第三章中的功能几乎相同。清单 8-16 中给出了该文件的实现。
extract_features()
函数有一个代表图像文件路径的参数。它使用 PIL 读取它,并使用convert
方法将其转换到 HSV 颜色空间。这个方法接受指定图像要被转换成 HSV 的HSV
字符串。然后,extract_features()
方法提取特征,根据所选索引的.npy
文件过滤特征元素,最后返回。使predict_outputs()
函数接受权重.npy
文件路径,然后使用NumPy
读取它,基于 ANN 对图像进行分类,并返回分类标签。
图 8-18
Android 上水果 360 数据集图像识别项目架构
import numpy
import PIL.Image
def sigmoid(inpt):
return 1.0/(1.0+numpy.exp(-1*inpt))
def relu(inpt):
result = inpt
result[inpt<0] = 0
return result
def predict_output(weights_mat_path, data_inputs, activation="relu"):
weights_mat = numpy.load(weights_mat_path)
r1 = data_inputs
for curr_weights in weights_mat:
r1 = numpy.matmul(a=r1, b=curr_weights)
if activation == "relu":
r1 = relu(r1)
elif activation == "sigmoid":
r1 = sigmoid(r1)
r1 = r1[0, :]
predicted_label = numpy.where(r1 == numpy.max(r1))[0][0]
return predicted_label
def extract_features(img_path):
im = PIL.Image.open(img_path).convert("HSV")
fruit_data_hsv = numpy.asarray(im, dtype=numpy.uint8)
indices = numpy.load(file="indices.npy")
hist = numpy.histogram(a=fruit_data_hsv[:, :, 0], bins=360)
im_features = hist[0][indices]
img_features = numpy.zeros(shape=(1, im_features.size))
img_features[0, :] = im_features[:im_features.size]
return img_features
Listing 8-16Fruits.py Module for Extracting Features and Classifying Images
清单 8-17 中给出了负责构建应用 UI 的 KV 文件first.kv
。值得一提的是,标签和按钮小部件的字体大小都是使用font_size
属性增加的。另外,调用classify_image()
方法来响应按钮部件on_press
事件。
BoxLayout:
orientation: "vertical"
Label:
text: "Predicted Class Appears Here."
font_size: 30
id: label
BoxLayout:
orientation: "horizontal"
Image:
source: "apple.jpg"
id: img
Button:
text: "Classify Image."
font_size: 30
on_press: app.classify_image()
Listing 8-17KV File of the Fruits Recognition Application
根据清单 8-18 ,在main.py
文件中可以找到classify_image()
方法的实现。该方法从 image 小部件的 source 属性加载要分类的图像的路径。该路径作为参数传递给水果模块中的extract_features()
函数。predict_output()
函数接受提取的特征、人工神经网络权重和激活函数。它在每一层的输入和它的权重之间的矩阵乘法之后返回分类标签。然后标签被打印在标签小部件上。
import kivy.app
import Fruits
class FirstApp(kivy.app.App):
def classify_image(self):
img_path = self.root.ids["img"].source
img_features = Fruits.extract_features(img_path)
predicted_class = Fruits.predict_output("weights.npy", img_features, activation="sigmoid")
self.root.ids["label"].text = "Predicted Class : " + predicted_class
firstApp = FirstApp(title="Fruits 360 Recognition.")
firstApp.run()
Listing 8-18Implementation of the main.py File of the Fruits Recognition Application
在开始构建 APK 文件之前,我们可以通过运行 Kivy 应用来确保一切正常。运行应用并按下按钮后,图像被分类;结果如图 8-19 所示。在确保应用成功运行之后,我们可以开始构建 Android 应用了。
图 8-19
分类图像后运行 Kivy 应用的结果
在使用 Buildozer 构建应用之前,必须生成buildozer.spec
文件。您可以使用buildozer init
命令自动创建它。值得注意的是,在应用内部,我们使用两个.npy
文件来表示过滤后的元素索引和权重。我们需要把它们放进 APK 的档案里。我们如何做到这一点?在buildozer.spec
文件中,有一个名为source.include_exts
的属性。它接受我们需要包含到 APK 文件中的所有文件的扩展名,用逗号分隔。这些文件位于应用的根目录下。例如,添加扩展名为py
、npy
、kv
、png
和jpg
的文件,属性如下:
source.include_exts = py,png,jpg,kv ,npy
成功执行应用的两个关键步骤是使用 PIL 将 RGB 图像转换为 HSV,以及使用 NumPy 中的matmul()
函数进行矩阵乘法。注意使用提供这些功能的库版本。
关于从 RGB 到 HSV 的转换,请确保使用新版本的 PIL 枕头。它只是 PIL 的一个扩展,可以毫无区别地导入和使用。关于矩阵乘法,只有 NumPy 1 . 10 . 0 版及更高版本支持。注意不要使用较低的版本。这留下了一个额外的问题,即如何告诉 P4A 我们需要使用特定版本的库。一种方法是在对应于 NumPy 的 P4A 配方中指定所需的版本。这些方法位于 Buildozer 安装目录下的 P4A 安装目录中。例如,根据图 8-20 使用版本 1.10.1。基于指定的版本,库将从 Python 包索引(PyPI)下载,并在构建应用时自动安装。注意,为 Android 准备 Kivy 的环境比它的使用更难。我们生活在一个准备开发环境比开发本身更难的时代。
图 8-20
指定要安装的 NumPy 版本
现在我们已经准备好构建 Android 应用了。我们可以使用命令buildozer android debug deploy run
在连接到开发机器的 Android 设备上构建、安装和运行应用。我们还可以使用logcat
工具来打印设备的调试信息。只要在命令的末尾加上这个词。构建成功后,Android 应用 UI 将如图 8-21 所示。
图 8-21
Android 应用的用户界面,用于对水果 360 数据集的图像进行分类
安卓上的 CNN
在第五章的第节中,我们创建了一个使用NumPy
从头构建 CNN 的项目。在本节中,这个项目将被打包到一个 Android 应用中,以便在设备上执行 CNN。项目结构如图 8-22 所示。numpycnn.py
文件包含第五章中讨论的用于构建 CNN 层的所有函数。名为main.py
的主应用文件有一个名为NumPyCNNApp
的子类。这就是 KV 文件应该命名为numpycnn.kv
的原因。buildozer.spec
文件类似于我们之前讨论过的。我们将简单地讨论主文件和它的 KV 文件。根据本章前面的讨论,预计项目这一部分的大部分内容会很清楚。
图 8-22
在 Android 上运行 CNN 的项目结构
我们将从清单 8-19 中的 KV 文件开始。根小部件是一个垂直的BoxLayout
,它有两个子部件GridLayout
。第一个GridLayout
小部件显示原始图像和 CNN 最后一层的结果。它被平均分配来容纳两个垂直的子BoxLayout
窗口小部件。每个布局都有标签和图像小部件。标签只是让它指示原始图像和结果图像的位置。
根小部件的第二个子部件GridLayout
,有三个小部件。第一个是一个Button
,当它被按下时,通过调用主 Python 文件中的start_cnn()
方法来执行 CNN。第二个是一个Label
,打印执行完所有 CNN 图层后结果的大小。最后,第三个子组件是一个TextInput
小部件,它允许用户以文本形式指定 CNN 的架构。例如,conv2,pool,relu
表示网络由三层组成:第一层是具有四个过滤器的 conv 层,第二层是平均池层,第三层是 ReLU 层。当应用运行时,其用户界面如图 8-23 所示。
BoxLayout:
orientation: "vertical"
GridLayout:
size_hint_y: 8
cols: 3
spacing: "5dp", "5dp"
BoxLayout:
orientation: "vertical"
Label:
id: lbl1
size_hint_y: 1
font_size: 20
text: "Original"
color: 0, 0, 0, 1
Image:
source: "input_image.jpg"
id: img1
size_hint_y: 5
allow_stretch: True
BoxLayout:
orientation: "vertical"
Label:
id: lbl2
size_hint_y: 1
font_size: 20
text: ""
color: 0, 0, 0, 1
Image:
id: img2
size_hint_y: 5
allow_stretch: True
GridLayout:
cols: 3
size_hint_y: 1
Button:
text: "Run CNN"
on_press: app.start_cnn()
font_size: 20
id: btn
Label:
text: "Click the button & wait."
id: lbl_details
font_size: 20
color: 0, 0, 0, 1
TextInput:
text: "conv4,pool,relu"
font_size: 20
id: cnn_struct
Listing 8-19KV File of the CNN Kivy Application
清单 8-20 中给出了main.py
文件的实现。这个文件的入口点是start_cnn()
方法。它从Image
小部件中读取图像路径,并使用我们在前面的例子中讨论过的 PIL 来读取它。为简单起见,使用convert()
方法将图像转换为灰色。字符L
将图像转换成灰色。按下Button
小部件后,该函数运行一个后台线程,该线程根据TextInput
中指定的结构执行 CNN。最后一层的结果返回给refresh_GUI()
方法。此方法在 UI 窗口上显示结果的第一个矩阵。
图 8-23
执行 CNN 的 Kivy 应用的主窗口
import kivy.app
import PIL.Image
import numpy
import numpycnn
import threading
import kivy.clock
class NumPyCNNApp(kivy.app.App):
def run_cnn_thread(self):
layers = self.root.ids["cnn_struct"].text.split(",")
self.root.ids["lbl_details.text"] = str(layers)
for layer in layers:
if layer[0:4] == "conv":
if len(self.curr_img.shape) == 2:
l_filter = numpy.random.rand(int(layer[4:]), 3, 3)
else:
l_filter = numpy.random.rand(int(layer[4:]), 3, 3, self.curr_img.shape[-1])
self.curr_img = numpycnn.conv(self.curr_img, l_filter)
print("Output Conv : ", self.curr_img.shape)
elif layer == "relu":
self.curr_img = numpycnn.relu(self.curr_img)
print("Output RelU : ", self.curr_img.shape)
elif layer == "pool":
self.curr_img = numpycnn.avgpooling(self.curr_img)
print("Output Pool : ", self.curr_img.shape)
elif layer[0:2] == "fc":
num_outputs = int(layer[2:])
fc_weights = numpy.random.rand(self.curr_img.size, num_outputs)
print("FC Weights : ", fc_weights.shape)
self.CNN_FC_Out = numpycnn.fc(self.curr_img, fc_weights=fc_weights, num_out=num_outputs)
print("FC Outputs : ", self.CNN_FC_Out)
print("Output FC : ", self.CNN_FC_Out.shape)
else:
self.root.ids["lbl_details"].text = "Check input."
break
self.root.ids["btn.text"] = "Try Again."
self.refresh_GUI()
def start_cnn(self):
img1 = self.root.ids["img1"]#Original Image
im = PIL.Image.open(img1.source).convert("L")
img_arr = numpy.asarray(im, dtype=numpy.uint8)
self.curr_img = img_arr
im_size = str(self.curr_img.shape)
self.root.ids["lbl_details"].text = "Original image size " + im_size
threading.Thread(target=self.run_cnn_thread).start()
self.root.ids["btn"].text = "Wait."
@kivy.clock.mainthread
def refresh_GUI(self):
im = PIL.Image.fromarray(numpy.uint8(self.curr_img[:, :, 0]))
layer_size = str(self.curr_img.shape)
im.save("res.png")
self.root.ids["img2"].source = "res.png"
self.root.ids["lbl2"].text = "Last Layer Result"
self.root.ids["lbl_details"].text = "Out size "+layer_size
if __name__ == "__main__":
NumPyCNNApp().run()
Listing 8-20Implementation of the Main File of the Kivy Application Executing CNN
线程执行run_cnn_thread()
方法。该方法从分割从TextInput
中检索的文本开始,分别返回每个层。基于 if 语句,从numpycnn.py
文件中调用合适的函数来构建指定的 CNN 层。例如,如果当前字符串是relu
,那么relu
函数将被调用。追加到conv
字符串的数字用作指定过滤器数量的参数。所有过滤器的形状都是 3×3。它们是随机填充的。如果有未识别的字符串,应用会在Label
上显示一条消息,指出输入有问题。该函数执行完毕后,返回到refresh_GUI()
方法。它显示返回的第一个矩阵,并在Label
上打印其大小。
此应用的修改版本允许运行所有三个连续的 conv、池和 ReLU 层,并显示所有这些层返回的结果。基于前三层(两个过滤器,带有两个过滤器的 conv 层,接着是池化,然后是 ReLU),所有返回的结果如图 8-24 所示。
图 8-24
基于三层 CNN (conv2,pool,relu)的所有层的结果
在确保应用在桌面上运行良好之后,构建应用剩下的唯一文件是buildozer.spec
文件。可以根据我们之前的讨论来准备。成功创建之后,我们可以像前面一样使用 Buildozer 开始构建它。在 Android 设备上运行应用后的用户界面如图 8-25 所示。
图 8-25
运行 Kivy 应用在 Android 设备上执行 CNN
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)