第2章
理解TensorFlow
在本章中,你将深入了解TensorFlow。这是一个开源分布式数值计算框架,它将成为我们实现所有练习的主要平台。
我们通过定义一个简单的计算并用TensorFlow实现它来作为TensorFlow的入门。在成功完成此操作后,我们将探讨TensorFlow是如何执行这个计算的。这将有助于我们理解该框架如何创建计算图来计算输出/并通过称为“会话”的方式执行此图。然后,通过将TensorFlow执行操作的方式与餐厅的运作进行类比,我们深入理解TensorFlow架构。
在对TensorFlow的运行方式有了良好的概念性和技术上的理解之后,我们将介绍该框架提供的一些重要的计算操作。首先,我们将讨论如何在TensorFlow中定义各种数据结构,比如变量、占位符和张量,同时我们还将介绍如何读取输入。然后,我们将执行一些与神经网络相关的操作(例如,卷积运算、定义损失函数和优化方法)。接下来,我们将学习如何使用作用域来重用和有效管理TensorFlow变量。最后,在练习中应用这些知识,实现一个可以识别手写数字图像的神经网络。
2.1 TensorFlow是什么
在第1章中,我们简要讨论了TensorFlow是什么。现在让我们更深入地认识它。Tensor-
Flow是由Google发布的开源分布式数值计算框架,主要用于减少在实现神经网络的过程中那些令人感到痛苦的细节(例如,计算神经网络权重的梯度)。TensorFlow使用计算统一设备架构(CUDA)来进一步有效实现这种数值计算,CUDA是由NVIDIA引入的并行计算平台。在https://www.tensorflow.org/api_docs/python/ 上有TensorFlow的应用程序编程接口(API),可以看到TensorFlow提供了数千种操作,这使我们的工作更轻松。
TensorFlow不是一夜之间开发出来的,它是有才华、善良的人们坚持不懈的成果。他们希望通过将深度学习带给更广泛的用户来使我们的生活发生变化。如果你有兴趣,可以访问https://github.com/tensorflow/tensorflow 查看TensorFlow代码。目前,TensorFlow拥有大约1000名贡献者,并且拥有超过25000次成果提交,它每天都在变得越来越好。
2.1.1 TensorFlow入门
现在让我们通过代码示例了解TensorFlow框架中的一些基本组件,让我们编写一个示例来执行以下计算,这对于神经网络非常常见:
这里W和x是矩阵,b是向量。然后,表示点积。sigmoid是一个非线性变换,由以下公式给出:
我们将逐步骤讨论如何通过TensorFlow进行此计算。
首先,我们需要导入TensorFlow和NumPy。在Python中运行与TensorFlow或NumPy相关的任何类型的操作之前,必须先导入它们:
接下来,我们将定义一个图对象,稍后我们将在这个对象上定义操作和变量:
图形对象包括计算图,计算图可以连接我们在程序中定义的各种输入和输出,以获得最终的所需输出(即它定义了如何根据图连接W,x和b来生成h)。例如,如果你将输出视为蛋糕,那么图就是使用各种成分(即输入)制作蛋糕的配方。此外,我们将定义一个会话对象,该对象将定义的图作为输入,以执行图。我们将在下一节详细讨论这些元素。
你可以用以下方式创建新的图对象,就像我们在上一个的例子里一样:
或者你可以用以下方式获取TensorFlow的默认计算图:
这两种方式都会在练习中使用。
现在我们定义一些张量,即x、W、b和h。张量在TensorFlow中基本上是n维数组。例如,一维向量或二维矩阵称为张量。在TensorFlow中有几种不同的方法可以定义张量,在这里,我们会讨论三种不同的方法:
1.首先,x是占位符。顾名思义,占位符没有初始化值,我们将在图执行时临时提供值。
2.接下来,我们有变量W和b。变量是可变的,这意味着它们的值可以随时间变化。
3.最后,我们有h,这是一个通过对x、W和b执行一些操作而产生的不可变张量:
另外,请注意,对于W和b,我们提供了一些重要的参数,如下所示:
它们称为变量初始化器,是最初赋值给W和b变量的张量。变量不能像占位符一样在没有初始值的情况下传递,并且我们需要一直为变量指定一些值。这里,tf.random_uniform意味着我们在minval(-0.1)和maxval(0.1)之间均匀地采样,以便将采样值赋给张量,而tf.zeros则用零初始化张量。在定义张量时,定义张量的形状也非常重要,shape属性定义张量的每个维度的大小。例如,如果形状是[10, 5],则意味着它将是一个二维结构,在第0维上有10个元素,在1维上有5个元素。
接下来,我们将运行初始化操作,初始化图中的变量W和b:
现在,我们执行该图,以获得我们需要的最终输出h。这是通过运行session.run(…)来完成的,我们提供占位符的值作为session.run()命令的参数:
最后,我们关闭会话,释放会话对象占用的资源:
下面是这个TensorFlow例子的完整代码。本章所有的示例代码都可以在ch2文件夹下的tensorf?low_introduction.ipynb中找到。
当你执行这段代码的时候,可能会遇到下面这样的警告:
不用担心这个,这个警告说你使用了现成的TensorFlow预编译版本,而没有在你的计算机上编译它,这完全没问题。如果你在计算机上进行编译,会获得稍微好一点的性能,因为TensorFlow将针对特定硬件进行优化。
在后面的几节中,我们将解释TensorFlow如何执行此代码,以生成最终输出。另请注意,接下来的两节可能有些复杂和偏技术。但是,即使你没有完全理解所有内容,也不必担心,因为在此之后,我们将通过一个完全是现实世界中的例子来进一步说明。我们会用在我们自己的餐厅Café Le TensorFlow里订单是如何完成的,来解释之前的相同执行过程。
2.1.2 TensorFlow客户端详细介绍
前面的示例程序称为TensorFlow客户端。在使用TensorFlow编写的任何客户端中,都有两种主要的对象类型:操作和张量。在前面的例子中,tf.nn.sigmoid是一个操作,h是张量。
然后我们有一个图对象,它是存储程序数据流的计算图。当我们在代码中依次添加x、W、b和h时,TensorFlow会自动将这些张量和任何操作(例如,tf.matmul())作为节点添加到图中。该图将存储重要信息,比如张量之间的依赖性以及在哪里执行什么运算。在我们的示例中,图知道要计算h,需要张量x、W和b。因此,如果在运行时没有正确初始化其中某一个,TensorFlow会指出需要修复的初始化错误。
接下来,会话扮演执行图的角色,它将图划分为子图,然后划分为更精细的碎片,之后将这些碎片分配给执行任务的worker。这是通过session.run(…)函数完成的,我们很快就会谈到它。为了之后引用方便,我们将这个例子称为sigmoid示例。
2.1.3 TensorFlow架构:当你执行客户端时发生了什么
我们知道TensorFlow非常善于创建一个包含所有依赖关系和操作的计算图,它可以确切地知道数据是如何及什么时候在哪里流转。但是,应该有一个元素可以有效执行定义好的计算图,使TensorFlow变得更好,这个元素就是会话。现在让我们来看看会话的内部,了解图的执行方式。
首先,TensorFlow客户端包含图和会话。创建会话时,它会将计算图作为tf.GraphDef协议缓冲区发送到分布式主服务器,tf.GraphDef是图的标准化表示。分布式主服务器查看图中的所有计算,并将计算切割后分配给不同的设备(例如,不同的GPU和CPU)。我们的sigmoid示例中的图如图2.1所示,图的单个元素称为节点。
接下来,计算图将由分布式主服务器分解为子图,并进一步分解为更小的任务。虽然在我们的例子中分解计算图似乎很微不足道,但在实际应用中,有多层隐藏层的神经网络解决方案的计算图可能是指数级增长的。此外,将计算图分解为多个部分来并行执行(例如,多个设备)变得越来越重要。执行图(或由这个图划分的子图)称为单个任务,任务会分配给单个TensorFlow服务器。
但是,实际上,每个任务都会分解为两个部分来执行,其中每个部分由一个worker执行:
- 一个worker使用参数的当前值执行TensorFlow操作(称为操作执行器)
- 另一个worker存储参数并在执行操作后更新它们的值(称为参数服务器)
TensorFlow客户端的常规工作流程如图2.2所示。
图2.3展示了图的分解过程。除了将图分解以外,TensorFlow还插入发送和接收节点,以帮助参数服务器和操作执行器相互通信。你可以把发送节点理解为一旦数据可用时发送数据,而接受节点在相应的发送节点发送数据时侦听和捕获数据。
最后,一旦计算完成,会话就会将更新的数据从参数服务器带回客户端。TensorFlow的体系结构如图2.4所示,这一解释基于https://www.tensorflow.org/extend/architecture 上的官方TensorFlow文档。
2.1.4 Cafe Le TensorFlow:使用类比理解TensorFlow
如果你对技术性说明中包含的信息感到不堪重负,下面我们尝试从不同的角度来介绍相关概念。假设有一家新咖啡馆开业了,你一直想去那。然后你去了那家咖啡馆,在靠窗的位置坐下。
接下来,服务员来请你下订单,你点了一个有奶酪没有西红柿的鸡肉汉堡。这里,请将你自己看作客户端,你的订单就是定义的图。该图定义了你需要什么以及相关信息。服务员类似于会话,他的责任是将订单带到厨房,以便执行订单。在接受订单时,服务员使用特定格式来传达你的订单,例如,桌号、菜单项ID、数量和特殊要求。可以把服务员用的格式化订单想象成GraphDef。然后,服务员把订单带到厨房,把它交给厨房经理。从这一刻开始,厨房经理负责执行订单。到这里,厨房经理代表分布式主服务器。厨房经理做出决定,例如需要多少厨师来制作菜肴,以及哪些厨师是最适合此工作的人选。我们假设每位厨师都有一位助理,他的职责是为厨师提供合适的食材、设备等。因此,厨房经理将订单交给一位厨师和一位厨师助理(虽然汉堡没有这么难准备),并要求他们准备好菜肴。在这里,厨师是操作执行器,助理是参数服务器。
厨师查看订单,告诉助理需要什么。因此,助理首先找到所需的材料(例如,面团、肉饼和洋葱),并尽快将它们准备在一起以满足厨师的要求。此外,厨师可能会要求暂时保留菜肴的中间结果(例如,切好的蔬菜),直到厨师再次需要它们。
汉堡准备好后,厨房经理会收到厨师和厨师助理做的汉堡,并通知服务员。此时,服务员从厨房经理那里取出汉堡带给你,你终于可以享用根据你的要求制作的美味汉堡,该过程如图2.5所示。
2.2 输入、变量、输出和操作
在了解底层架构之后,我们将介绍构成TensorFlow客户端的最常见元素。如果你阅读过网上数百万个TensorFlow客户端中的任何一个,那么它们(TensorFlow相关的代码)都属于下面这些类型之一:
- 输入:用来训练和测试算法的数据。
- 变量:可变的张量,大部分用于定义算法的参数。
- 输出:不可变的张量,用于存储中间和最终的输出。
- 操作:对输入做不同的变换以产生想要的输出。
在之前的sigmoid示例中,我们可以找到所有这些类别的实例,表2.1中列出这些元素:
下面更详细地解释每个TensorFlow元素。
2.2.1 在TensorFlow中定义输入
客户端主要以三种方式接收数据:
- 使用Python代码在算法的每个步骤中提供数据
- 将数据预加载并存储为TensorFlow张量
- 搭建输入管道
让我们来看看这些方式。
2.2.1.1 使用Python代码提供数据
在第一种方法中,可以使用传统的Python代码将数据馈送到TensorFlow客户端。在之前的示例中,x是这种方法的一个例子。为了从外部数据结构(例如,numpy.ndarray)向客户端提供数据,TensorFlow库提供了一种优雅的符号数据结构,称为占位符,它被定义为tf.placeholder(…)。顾名思义,占位符在图的构建阶段不需要实际数据,相反,仅在图执行过程中通过session.run(…,feed_dict = {placeholder:value})将外部数据以Python字典的形式传递给feed_dict参数,其中键是tf .placeholder变量,相应的值是实际数据(例如,numpy.ndarray)。占位符定义采用以下形式:
参数如下:
- dtype:这是送入占位符的数据类型
- shape:这是占位符的形状,以1维向量形式给出
- name:这是占位符的名称,这对调试很重要
2.2.1.2 将数据预加载并存储为张量
第二种方法类似于第一种方法,但可以少担心一件事。由于数据已预先加载,因此我们无须在图的执行期间提供数据。为了明白这一点,让我们修改我们的sigmoid示例。请记住,我们之前将x定义为占位符:
现在,让我们将其定义为包含特定值的张量:
以下是完整的代码:
你会注意到,代码与我们原来的sigmoid示例有两个主要区别。首先,我们定义x的方式不同,我们现在直接指定一个特定的值并将x定义为张量,而不是使用占位符对象并在图执行时输入实际值。另外,正如你所看到的,我们没有在session.run(…)中提供任何额外的参数。但缺点是,现在你无法在session.run(…)中向x提供不同的值,并看到输出是如何改变的。
2.2.1.3 搭建输入管道
输入管道是专门为需要快速处理大量数据的更重型客户端设计的。实际上会创建一个保存数据的队列,直到我们需要它为止。TensorFlow还提供各种预处理步骤(例如,用于调整图像对比度/亮度,或进行标准化),这些步骤可在将数据送到算法之前执行。为了提高效率,可以让多个线程并行读取和处理数据。
一个典型的通道包含以下元素:
- 文件名列表
- 文件名队列,用于为输入(记录)读取器生成文件名
- 记录读取器,用于读取输入(记录)
- 解码器,用于解码读取记录(例如,JPEG图像解码)
- 预处理步骤(可选)
- 一个示例(即解码输入)队列
让我们使用TensorFlow编写一个输入管道的快速示例。在这个例子中,我们有三个CSV格式的文本文件(text1.txt、text2.txt和text3.txt),每个文件有五行,每行有10个用逗号分隔的数字(示例行:0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)。我们需要从文件一直到表示文件中那些输入的张量,搭建一个输入管道,来分批读取数据(多行数据向量),下面一步一步地介绍这个过程。
有关更多信息,请参阅官方TensorFlow页面上有关导入数据的内容:https://www.tensorflow.org/programmers_guide/reading_data 。
首先,像以前一样导入一些重要库:
接着,定义图和会话对象:
然后,定义一个文件名队列,这是一个包含文件名的队列数据结构。它将作为参数传递给读取器(很快将被定义)。队列将根据读取器的请求生成文件名,以便读取器可以用这些文件名访问文件以读取数据:
这里,capacity是给定时间队列持有的数据量,shuffle告诉队列是否应该在吐出数据之前将其打乱。
TensorFlow有几种不同类型的读取器(https://www.tensorflow.org/api_guides/python/io_ops#Readers 提供了可用读取器列表)。由于我们有一些单独的文本文件,其中一行代表一个单独的数据点,因此TextLineReader最适合我们:
定义读取器后,我们可以使用read()函数从文件中读取数据,它的输出是键值对,其中,键标识文件和文件中正在读取的记录(即文本行),我们可以省略它,而值返回读取器读取的行的实际值:
接下来,我们定义record_defaults,如果发现任何错误记录,将输出它:
现在我们将读取到的文本行解码为数字列(因为我们有CSV文件),为此,我们使用decode_csv()方法。如果使用文本编辑器打开文件(例如,test1.txt),你会看到每一行有10列:
然后,我们把这些列拼接起来,形成单个张量(称为特征),这些张量被传给另一个方法tf.train.shuff?le_batch(),该方法的输入是前面定义的张量(特征),然后将张量进行打乱按批次输出:
batch_size参数是在给定的步骤中对数据采样的批次大小,capacity是数据队列的容量(大队列需要更多内存),min_after_dequeue表示出队后留在队列中的最小元素数量。最后,num_threads定义用于生成一批数据的线程数。如果管道中有大量的预处理,则可以增加此线程数。此外,如果需要在不打乱数据(使用tf.train.shuffle_batch)的情况下读取数据,则可以使用tf.train.batch操作。然后,我们将通过调用以下代码启动此管道:
可以将类tf.train.Coordinator()视为线程管理器,它实现了各种管理线程的机制(例如,启动线程并在任务完成后让线程加入主线程)。我们需要tf.train.Coordinator()类,因为输入管道会产生许多线程来执行队列填充(即入队)、队列出队和许多其他任务。接下来,我们将使用之前创建的线程管理器执行tf.train.start_queue_runners(…)。QueueRunner()保存队列的入队操作,并在定义输入管道时自动创建它们。因此,要填充已定义的队列,我们需要使用tf.train.start_queue_runners函数启动这些队列运行程序。
接下来,在我们感兴趣的任务完成之后,我们需要显式地停止线程,并让它们加入主线程,否则程序将无限期挂起,这是通过coord.request_stop()和coord.join(threads)来实现的。这种输入管道可以与我们的sigmoid示例相合,以便它直接从文件中读取数据,如下所示:
2.2.2 在TensorFlow中定义变量
变量在TensorFlow中扮演重要角色。变量本质上是具有特定形状的张量,而形状定义了变量有多少维度以及每个维度的大小。然而,与常规张量不同,变量是可变的,这意味着变量的值在定义后可以改变。这对于需要改变模型参数(例如,神经网络权重)的学习模型来说是理想特性,其权重在每个学习步骤之后会稍微变化。例如,如果使用x = tf.Variable(0, dtype = tf.int32)定义变量,则可以使用TensorFlow操作(比如tf.assign(x,x + 1))更改该变量的值。
但是,如果像x = tf.constant(0,dtype = tf.int32)这样定义张量,则无法像对变量一样更改张量的值,它会一直保持为0,直到程序执行结束。
变量创建非常简单,在我们的例子中,我们已经创建了两个变量W和b。在创建变量时,有一些事情非常重要,我们在这里列出它们并在以下段落中详细讨论:
- 变量形状
- 数据类型
- 初始值
- 名称(可选)
变量形状是[x,y,z,…]格式的一维向量。列表中的每个值表示相应维度或轴的大小。例如,如果需要具有50行和10列的二维张量作为变量,则形状是[50, 10]。
变量的维数(即形状矢量的长度)在TensorFlow中被看作张量的秩,不要将它与矩阵的秩混淆。
TensorFlow中,张量的秩表示张量的维数,对于二维矩阵,秩= 2。
数据类型在决定变量大小方面起着重要作用。有许多不同的数据类型,包括常用的tf.bool、tf.uint8、tf.f?loat32和tf.int32。每种数据类型都需要一定的比特数来表示该类型的值。例如,tf.uint8需要8比特,而tf.f?loat32需要32比特。通常的做法是使用相同的数据类型进行计算,否则会导致数据类型不匹配。因此,如果你有两个不同数据类型的张量,则需要对它们做数据类型转换,因而必须使用tf.cast(…)操作将一个张量显式转换为另一个类型的张量。tf.cast(…)操作就是为了应对这种情况而设计的。例如,如果有一个tf.int32类型的x变量,需要将其转换为tf.f?loat32,则可以通过tf.cast(x,dtype = tf.f?loat32)将x转换为tf.f?loat32。
接下来,变量需要用初始值进行初始化。为方便起见,TensorFlow提供了几种不同的初始化器,包括常数初始化器和正态分布初始化器。以下是一些可用于初始化变量的流行TensorFlow初始化器:
- tf.zeros
- tf.constant_initializer
- tf.random_uniform
- tf.truncated_normal
最后,我们会将变量的名称用作ID在图中标识该变量。因此,如果你可视化计算图,那么变量将显示为传递给name关键字的参数。如果未指定名称,TensorFlow将使用默认命名方案。
请注意,计算图并不知道被tf.Variable赋值的Python变量,该变量不是TensorFlow变量命名的一部分。例如,如果定义如下TensorFlow变量:
则TensorFlow计算图知道这个变量的名称是b,而不是a。
2.2.3 定义TensorFlow输出
TensorFlow输出通常是张量,并且结果要么转换为输入,要么转换为变量,或两者都有。在我们的例子中,h是一个输出,其中h = tf.nn.sigmoid(tf.matmul(x,W)+ b)。也可以将这些输出提供给其他操作,形成一组链式操作,此外,它不一定必须是TensorFlow操作,也可以在TensorFlow中使用Python算术运算。这是一个例子:
2.2.4 定义TensorFlow操作
如果看一看https://www.tensorflow.org/api_docs/python/ 上的TensorFlow API,会看到TensorFlow有数量巨大的可用操作。
在这里,我们选择其中几个进行介绍。
2.2.4.1 比较操作
比较操作对于比较两个张量非常有用。以下代码示例包含一些有用的比较操作。你可以在https://www.tensorflow.org/api_guides/python/control_flow_ops 的比较运算符部分中找到比较运算符的完整列表。此外,为了理解这些操作的工作原理,让我们考虑两个示例张量x和y:
2.2.4.2 数学运算
TensorFlow允许对从简单到复杂的张量执行数学运算,我们将讨论TensorFlow提供的几个数学运算,在https://www.tensorflow.org/api_guides/python/math_ops可以看到完整的列表。
2.2.4.3 分散和聚合操作
分散和聚合操作在矩阵操作任务中起着至关重要的作用,因为这两种操作的变体是在TensorFlow中索引张量的唯一方法(直到最近)。换句话说,你不能像在NumPy中那样访问TensorFlow中的张量元素(例如,x [1, 0],其中x是2D numpy.ndarray)。分散操作允许你将值分配给给定张量的特定索引,而聚合操作允许你提取给定张量的切片(即个体元素)。以下代码显示分散和聚合操作的几个变体:
2.2.4.4 神经网络相关操作
现在让我们看看几个有用的神经网络相关的操作,我们将在后面的章节中大量使用它们。在这里讨论的操作涵盖了从简单的逐元素变换(即激活),到计算一组参数相对于另一个值的偏导数,我们还会实现一个简单的神经网络作为练习。
(1)神经网络中使用的非线性激活
非线性激活使神经网络能够在许多任务中表现良好。通常,在神经网络中的每个层输出之后都会有非线性激活变换(即激活层)(除最后一层之外)。非线性变换有助于神经网络学习数据中存在的各种非线性模式。这对于现实中复杂的问题非常有用,与线性模式相比,数据通常具有更复杂的非线性模式。如果层之间没有非线性激活,深层神经网络将是一堆相互堆叠的线性变换层。而且,一组线性层基本上可以压缩成单个较大的线性层。总之,如果没有非线性激活,我们就无法创建具有多层的神经网络。
让我们通过一个例子来观察非线性激活的重要性。首先,回想一下我们在sigmoid示例中看到的神经网络的计算。如果我们忽视b,它将是这样的:
假设一个三层神经网络(每层的权重为W1、W2和W3),每个层都执行上面的计算,完整的计算如下所示:
但是,如果去掉非线性激活函数(就是sigmoid),就会是这样:
因此,在没有非线性激活的情况下,可以将三层减少成单个线性层。
现在,我们将列出神经网络中两种常用的非线性激活,以及它们如何在TensorFlow中实现:
(2)卷积操作
卷积运算是一种广泛使用的信号处理技术。对于图像,使用卷积可以产生图像的不同效果。使用卷积进行边缘检测的示例如图2.6所示,其实现方法是在图像顶部移动卷积滤波器,从而在每个位置产生不同的输出(参见本节后面的图2.7)。具体来说,在每个位置,对于与卷积滤波器重叠的图像块(与卷积滤波器大小相同),在卷积滤波器中对其元素进行逐元素相乘相加,并对结果求和:
以下是卷积操作的实现:
在这里,过多的方括号可能会让你认为去掉这些冗余括号可以很容易地理解这个例子,不幸的是,事实并非如此。对于tf.conv2d(…)操作,TensorFlow要求input、filter和strides具有精确的格式。现在我们将更详细地介绍tf.conv2d(input, filter, strides, padding)中的每个参数:
-
input:这通常是4D张量,其维度应按[batch_size,height,width,channels]排序。
-
batch_size:这是单批数据中的数据量(例如,如图像和单词的输入)。我们通常批量处理数据,因为进行学习的数据集很大。在给定的训练步骤,我们随机抽样一小批数据,这些数据近似代表完整的数据集。通过许多次执行此操作,我们可以很好地逼近完整的数据集。这个batch_size参数与我们在TensorFlow输入管道示例中讨论的参数相同。
- height和width:这是输入的高度和宽度。
- channels:这是输入的深度(例如,对于RGB图像其值为3,表示有3个通道)。
-
-
filter:这是一个4D张量,表示卷积运算的卷积窗口,其维度应为[height,width,in_channels,out_channels]:
- height和width:这是卷积核的高度和宽度(通常小于输入的高度和宽度)
- in_channels:这是该层的输入的通道数
- out_channels:这是要在该层的输出中生成的通道数
- strides:这是一个包含四个元素的列表,其中元素是[batch_stride,height_stride,width_stride,channels_stride]。strides参数表示卷积窗口在输入上单次滑动期间要跳过的元素数。如果你不完全了解步长是什么,则可以使用默认值1。
- padding:这可以是['SAME','VALID']之一,它决定如何处理输入边界附近的卷积运算。VALID操作在没有填充的情况下执行卷积。如果我们用大小为h的卷积窗口卷积长度为n的输入,则输出大小为(n - h + 1 < n),输出大小的减小会严重限制神经网络的深度。SAME将用零填充边界,使输出具有与输入相同的高度和宽度。要更好地了解卷积核大小、步长和填充是什么,请参见图2.7。
(3)池化操作
池化操作的行为与卷积操作类似,但最终输出不同。池化操作取该位置的图像块的最大值,而不是输出卷积核和图像块的逐元素相乘的总和(参见图2.8)。
(4)定义损失
我们知道,为了让神经网络学习有用的东西,需要定义一个损失。在TensorFlow中有几种可以自动计算损失的函数,其中两种函数如下面的代码所示。tf.nn.l2_loss函数是均方误差损失,而tf.nn.softmax_cross_entropy_with_logits_v2是另一种类型的损失,在分类任务中它有更好的性能。这里的logits指的是神经网络的没有归一化的输出(即神经网络最后一层的线性输出):
(5)优化神经网络
在定义了神经网络的损失之后,我们的目标是尽量减少这种损失,优化就是用于此的过程。换句话说,优化器的目标是找到对于所有输入均给出最小损失的神经网络参数(即权重和偏差值)。同样,TensorFlow提供了几种不同的优化器,因此,我们不必从头开始实现它们。
图2.9展示一个简单的优化问题,以及优化是如何随时间进行的。曲线可以想象为损失曲线(对于高维,则是损失曲面),其中x可以被认为是神经网络的参数(在这里,是具有单个权重的神经网络),而y可以被认为是损失。起点设为x = 2,从这一点开始,我们使用优化器来达到在x = 0时获得的最小值y(即损失)。更具体地说,我们在给定点的与梯度相反的方向上移动一些小步长,并以这种方式继续走几个步长。然而,在实际问题中,损失曲面不会像图中那样好,它会更复杂:
在此示例中,我们使用GradientDescentOptimizer。learning_rate参数表示在最小化损失方向上的步长(两个点之间的距离):
每次使用session.run(minimize_op)执行最小化损失运算时,都会接近给出最小值tf_y的tf_x值。
(6)控制流操作
控制流操作,顾名思义,控制图中元素执行的顺序。例如,假设我们需要按顺序执行以下计算:
确切地说,如果x = 2,我们应该得到z = 14。让我们首先尝试以最简单的方式实现这一点:
理想情况下,我们希望x = 7和z = 14,但是,TensorFlow产生x = 2和z = 4。这不是你期待的答案。这是因为除非你明确指定,否则TensorFlow不关心事物的执行顺序。控制流操作就是使你能控制执行顺序的操作。要修复上述代码,我们执行以下操作:
现在,结果应该是x = 7和z = 14。tf.control_dependencies(…)操作确保在执行嵌套操作之前将执行作为参数传递给它的运算。
2.3 使用作用域重用变量
到目前为止,我们已经了解了TensorFlow的体系结构,以及实现基本TensorFlow客户端所需的基本知识。然而,TensorFlow还有更多内容。正如我们已经看到的,TensorFlow的行为与典型的Python脚本完全不同。例如,你无法实时调试TensorFlow代码(但可以用Python IDE执行简单的Python脚本),因为在TensorFlow中计算不会实时发生(除非你使用的是Eager执行方法,它最近出现在TensorFlow1.7中:https://research.googleblog.com/2017/10/eager-execution-imperative-def?ine-by.html )。换句话说,TensorFlow首先定义完整的计算图,再在设备上执行所有计算,最后得到结果。因此,调试TensorFlow客户端可能会非常烦琐和痛苦,这强化了在实现TensorFlow客户端时注意细节的重要性。因此,建议遵循为TensorFlow引入的正确编码规范。一种这样的规范被称为“作用域”,并允许更容易的变量重用。
重用TensorFlow变量是TensorFlow客户端中经常出现的情况。要理解答案的价值,我们必须首先理解这个问题。此外,错误的代码可以帮助我们更好理解这个问题。
假设我们想要一个执行某种计算的函数:给定w,需要计算x w + y * 2。让我们编写一个TensorFlow客户端,它具有执行此操作的函数:
假设你想要在某一步计算它,然后,你可以调用session.run(very_simple_computation(2))
(当然,在调用tf.global_variables_initializer().run()之后),之后你会得到结果,并对编写实际有效的代码感觉良好。但是,情况可能相反,因为多次运行此函数会出现问题。每次调用此方法时,都会创建两个TensorFlow变量。还记得我们讨论过TensorFlow与Python不同吗?这就是一个这样的例子。多次调用此方法时,图中的x和y变量不会被替换。相反,将保留旧变量,并在图中创建新变量,直到内存不足为止。但是,结果是正确的。要查看此操作,请在for循环中运行session.run(very_simple_computation(2)),如果打印图中变量的名称,将看到两个以上变量。
这是运行10次时的输出:
每次运行该函数时,都会创建一对变量。让我们明确一点:如果你运行这个函数100次,你的图中将有198个过时变量(99个x变量和99个y变量)。
这是作用域可以解决的问题。作用域允许你重用变量,而不是每次调用函数时都创建一个变量。现在为我们的小例子添加可重用性,我们将代码更改为以下内容:
在这个例子中,如果执行session.run([z1,z2,a1,a2,zz1,zz2]),应该看到z1、z2、a1、a2、zz1、zz2的值依次为9.0、90.0、9.0、90.0、9.0、90.0。现在,如果打印变量,你应该只看到四个不同的变量:scopeA/x,scopeA/y,scopeB/x和scopeB/y。我们现在可以在循环中多次运行它,而不必担心创建冗余变量和内存不足。
现在,你可能想知道为什么不能在代码的开头创建四个全局变量,并在之后的方法中使用它们。因为这会破坏代码的封装,这样一来,代码将明确依赖于该代码块之外的内容。
总之,作用域允许可重用性,同时保留代码的封装性。此外,作用域使代码更直观,并减少出错的可能性,因为我们可以通过域和名称显式获取变量,而不是使用被TensorFlow变量赋值的Python变量。
2.4 实现我们的第一个神经网络
在了解了TensorFlow的架构、基础知识和作用域机制之后,我们现在应该实现比较复杂的东西:一个神经网络。准确地说,我们将实现一个我们在第1章自然语言处理简介中讨论过的全连接的神经网络模型。
神经网络能被引入的原因之一是能够用它对数字进行分类。对于此任务,我们使用http://yann.lecun.com/exdb/mnist/ 上提供的著名的MNIST数据集。你可能对我们使用计算机视觉任务而不是NLP任务感到有点疑惑,这是因为视觉任务可以通过较少的预处理来实现,并且易于理解。
由于这是我们第一次接触神经网络,我们将详细介绍示例的主要部分。但请注意,我只会介绍练习中的关键部分。要从头到尾运行示例,可以在ch2文件夹中的tensorf?low_introduction.ipynb文件内找到完整练习。
2.4.1 准备数据
首先,我们需要使用maybe_download(…)函数下载数据集,并使用read_mnist(…)函数对其进行预处理。这两个函数在练习文件中定义。read_mnist(…)函数主要执行两个步骤:
- 读取数据集的字节流,并将其转变为适当的numpy.ndarray对象
- 将图像标准化为均值为0和方差为1(也称为白化)
以下代码显示read_mnist(…)函数。
read_mnist(…)函数将包含图像文件的文件名和包含标签文件的文件名作为输入,然后生成两个包含所有图像及其相应标签的NumPy矩阵:
2.4.2 定义TensorFLow图
要定义TensorFlow图,我们首先要为输入图像(tf_inputs)和相应的标签(tf_labels)定义占位符:
接下来,我们将编写一个Python函数,它将首次创建变量。
请注意,我们使用作用域来确保可重用性,并确保正确命名变量:
接下来,我们定义神经网络的推理过程。与使用没有作用域的变量相比,请注意作用域是如何为函数中的代码提供非常直观的流程的。这个网络有三层:
- 具有ReLU激活的全连接层(第一层)
- 具有ReLU激活的全连接层(第二层)
- 完全连接的softmax层(输出)
借助于作用域,我们将每个层的变量(权重和偏差)命名为layer1/weights、layer1/bias、layer2/weights、layer2/bias、output/weights和output/bias。注意,在代码中,它们都具有相同的名称,但作用域不同:
现在,我们定义一个损失函数,然后定义最小化损失运算。最小化损失运算通过将网络参数推向最小化损失的方向来最小化损失。TensorFlow中提供了多种优化器,在这里,我们将使用MomentumOptimizer,它比GradientDescentOptimizer有更好的准确率和收敛性:
最后,我们定义一个运算来获得给定的一批输入的softmax预测概率,它可以用于计算神经网络的准确率:
2.4.3 运行神经网络
现在,我们有了运行神经网络所需的所有必要操作,下面我们检查它是否能够成功学习对数字的分类:
在此代码中,accuracy(test_predictions, test_labels)是一个函数,它接受预测的结果和标签作为输入,并提供准确率(与实际标签匹配的预测数量),它在练习文件中定义。
如果运行成功,你应该能够看到类似于图2.10中所示的结果。50个迭代周期后,测试准确率应达到约98%:
2.5 总结
在本章中,你通过了解我们实现算法的主要的底层平台(TensorFlow),迈出了解决NLP任务的第一步。首先,我们讨论了TensorFlow架构的基本细节。接下来,我们讨论了一个有意义TensorFlow客户端的基本要素。然后我们讨论了TensorFlow中广泛使用的一般编码规范,称为作用域。后来,我们将所有这些元素组合在一起,实现了一个对MNIST数据集进行分类的神经网络。
具体来说,我们讨论了TensorFlow架构,并使用TensorFlow客户端示例进行说明。在TensorFlow客户端中,我们定义了TensorFlow图。然后,我们创建一个会话,它会查看这个图,创建一个表示图的GraphDef对象,并将其发送给分布式主服务器。分布式主服务器查看图,确定用于计算的相关组件,并将其划分为多个子图以使计算速度更快。最后,worker执行子图并通过会话返回结果。
接下来,我们讨论了构成一个典型TensorFlow客户端的各种元素:输入、变量、输出和操作。输入是我们提供给算法的数据,用于训练和测试。我们讨论了三种不同的输入方式:使用占位符,将数据预加载并存储为TensorFlow张量,以及使用输入管道。然后我们讨论了TensorFlow变量,它们与其他张量如何区别,以及如何创建和初始化它们。在此之后,我们讨论了如何使用变量来创建中间及最终输出。最后,我们讨论了几个可用的TensorFlow操作,例如数学运算、矩阵运算、神经网络相关运算和控制流运算,这些运算将在本书后面使用。
然后,我们讨论了在实现TensorFlow客户端时如何使用作用域来避免某些陷阱。作用域使我们可以轻松使用变量,同时保持代码的封装性。
最后,我们使用所有之前学过的概念实现了一个神经网络,我们使用三层神经网络对MNIST数字数据集进行分类。
在下一章中,将介绍如何使用我们在本章中实现的全连接神经网络来学习单词的语义数值表示。